refactor: implement modular monorepo packages

- Extract database schema and migrations into @openreader/database

- Extract startup and orchestration scripts into @openreader/bootstrap

- Move compute-worker into packages/compute-worker

- Remove runtime dependency on drizzle-kit

- Update Dockerfile to deploy isolated packages without merging node_modules
This commit is contained in:
Richard R 2026-06-13 13:51:18 -06:00
parent 2abadde949
commit 358a56cc9a
211 changed files with 497 additions and 466 deletions

View file

@ -62,13 +62,13 @@ jobs:
platform: linux/amd64 platform: linux/amd64
runner: ubuntu-24.04 runner: ubuntu-24.04
context: . context: .
dockerfile: ./compute-worker/Dockerfile dockerfile: ./packages/compute-worker/Dockerfile
- image_target: compute-worker - image_target: compute-worker
arch: arm64 arch: arm64
platform: linux/arm64 platform: linux/arm64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
context: . context: .
dockerfile: ./compute-worker/Dockerfile dockerfile: ./packages/compute-worker/Dockerfile
steps: steps:
- name: Checkout repository - name: Checkout repository

View file

@ -20,8 +20,9 @@ WORKDIR /app
# Copy workspace manifests needed for dependency installation # Copy workspace manifests needed for dependency installation
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY compute-worker/package.json ./compute-worker/package.json COPY packages/bootstrap/package.json ./packages/bootstrap/package.json
COPY docker/entrypoint-migration-tools/package.json ./docker/entrypoint-migration-tools/package.json COPY packages/compute-worker/package.json ./packages/compute-worker/package.json
COPY packages/database/package.json ./packages/database/package.json
# Install dependencies # Install dependencies
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@ -32,8 +33,9 @@ COPY . .
# Build the Next.js application # Build the Next.js application
RUN pnpm exec next telemetry disable RUN pnpm exec next telemetry disable
RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/entrypoint-migration-tools deploy /opt/entrypoint-migration-tools RUN pnpm --config.inject-workspace-packages=true --filter @openreader/bootstrap deploy /opt/openreader/bootstrap
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/embedded-compute-worker RUN pnpm --dir /opt/openreader/bootstrap rebuild better-sqlite3 ffmpeg-static
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/openreader/embedded-compute-worker
# Generate third-party dependency license report plus copied license files. # Generate third-party dependency license report plus copied license files.
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \ RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
pnpm dlx license-checker-rseidelsohn@4.3.0 \ pnpm dlx license-checker-rseidelsohn@4.3.0 \
@ -62,34 +64,15 @@ COPY --from=app-builder /app/.next/standalone ./
COPY --from=app-builder /app/.next/static ./.next/static COPY --from=app-builder /app/.next/static ./.next/static
COPY --from=app-builder /app/public ./public COPY --from=app-builder /app/public ./public
# Copy the entrypoint and migration/runtime helper files it invokes directly. # Ship startup orchestration and the embedded worker as independent deployed bundles.
COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs COPY --from=app-builder /opt/openreader/bootstrap /opt/openreader/bootstrap
COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs COPY --from=app-builder /opt/openreader/embedded-compute-worker /opt/openreader/embedded-compute-worker
COPY --from=app-builder /app/drizzle ./drizzle
COPY --from=app-builder /app/drizzle.config.pg.ts ./drizzle.config.pg.ts
COPY --from=app-builder /app/drizzle.config.sqlite.ts ./drizzle.config.sqlite.ts
COPY --from=app-builder /app/src/db ./src/db
# Merge in the dependency subset needed by the entrypoint migration scripts.
COPY --from=app-builder /opt/entrypoint-migration-tools/node_modules /tmp/runtime-tools-node_modules
RUN mkdir -p /app/node_modules && \
rm -rf /app/node_modules/pg && \
rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \
/tmp/runtime-tools-node_modules/better-sqlite3 \
/tmp/runtime-tools-node_modules/ffmpeg-static && \
cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \
rm -rf /tmp/runtime-tools-node_modules
# Drizzle Kit discovers the Postgres driver through ESM import during startup migrations.
RUN node --input-type=module -e "await import('pg')"
# Ship the embedded compute worker as a separate deployed bundle.
COPY --from=app-builder /opt/embedded-compute-worker ./embedded-compute-worker
# Include third-party license report and copied license texts at a stable path in the image. # Include third-party license report and copied license texts at a stable path in the image.
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
# Include SeaweedFS license text for the copied weed binary. # Include SeaweedFS license text for the copied weed binary.
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
# Include static model notices for runtime-downloaded assets. # Include static model notices for runtime-downloaded assets.
COPY --from=app-builder /app/compute-worker/src/inference/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt COPY --from=app-builder /app/packages/compute-worker/src/inference/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
# Copy seaweedfs weed binary for optional embedded local S3. # Copy seaweedfs weed binary for optional embedded local S3.
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
@ -99,7 +82,7 @@ COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server
RUN chmod +x /usr/local/bin/nats-server RUN chmod +x /usr/local/bin/nats-server
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts. # Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
COPY --from=app-builder /app/compute-worker/src/inference/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt COPY --from=app-builder /app/packages/compute-worker/src/inference/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
# Match the app's historical container port now that standalone server.js # Match the app's historical container port now that standalone server.js
# is started directly instead of `next start -p 3003`. # is started directly instead of `next start -p 3003`.
@ -109,5 +92,5 @@ ENV PORT=3003
EXPOSE 3003 EXPOSE 3003
# Start the application # Start the application
ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"] ENTRYPOINT ["node", "/opt/openreader/bootstrap/src/cli.mjs", "--"]
CMD ["node", "server.js"] CMD ["node", "server.js"]

View file

@ -1,14 +0,0 @@
{
"name": "@openreader/entrypoint-migration-tools",
"version": "0.0.0",
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.1061.0",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"ffmpeg-static": "^5.3.0",
"pg": "^8.21.0"
}
}

View file

@ -103,7 +103,7 @@ services:
image: openreader-compute-worker:local image: openreader-compute-worker:local
build: build:
context: ../.. context: ../..
dockerfile: compute-worker/Dockerfile dockerfile: packages/compute-worker/Dockerfile
depends_on: depends_on:
nats: nats:
condition: service_started condition: service_started

View file

@ -7,6 +7,18 @@ import TabItem from '@theme/TabItem';
This page covers migration behavior for both database schema and storage data in OpenReader. This page covers migration behavior for both database schema and storage data in OpenReader.
## Runtime ownership
- `@openreader/database` owns database clients, schemas, SQL migration files, and programmatic
migration execution for SQLite and PostgreSQL.
- `@openreader/bootstrap` owns startup orchestration, storage migration, and optional embedded
SeaweedFS, NATS, and compute-worker processes.
- The Next.js app imports `@openreader/database` directly, but does not orchestrate migrations or
child processes.
Docker deploys bootstrap as an isolated runtime bundle under `/opt/openreader/bootstrap`; it does
not merge migration dependencies into the standalone Next.js app under `/app`.
## Startup migration behavior ## Startup migration behavior
By default, the shared entrypoint runs migrations automatically before app startup in: By default, the shared entrypoint runs migrations automatically before app startup in:
@ -53,11 +65,6 @@ In most cases, you do not need manual migration commands because startup runs mi
- Postgres when `POSTGRES_URL` is set - Postgres when `POSTGRES_URL` is set
- SQLite when `POSTGRES_URL` is unset - SQLite when `POSTGRES_URL` is unset
You can always override the target explicitly with `--config`.
<Tabs groupId="apply-migration-commands">
<TabItem value="project-scripts" label="Project Scripts" default>
```bash ```bash
# Run pending migrations for one target: # Run pending migrations for one target:
# - Postgres if POSTGRES_URL is set # - Postgres if POSTGRES_URL is set
@ -71,26 +78,15 @@ pnpm migrate-fs
pnpm migrate-fs:dry-run pnpm migrate-fs:dry-run
``` ```
</TabItem> `pnpm migrate` uses the programmatic Drizzle migrator from `@openreader/database`. Drizzle Kit is
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd"> not a production or startup dependency; it is used only to generate new migration files.
```bash
# Migrate SQLite
pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
# Migrate Postgres
pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
```
</TabItem>
</Tabs>
## Generate migrations ## Generate migrations
`pnpm generate` is a two-phase script for contributors and schema changes: `pnpm generate` is a two-phase script for contributors and schema changes:
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`). 1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth). 2. **Drizzle migration generation** — runs `drizzle-kit generate` for both configs in `packages/database`, producing SQL migration files from all schema files (app + auth).
:::note :::note
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files. Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
@ -100,22 +96,23 @@ Most users do not need to run `pnpm generate`. Use it when contributing or when
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited: Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
- `src/db/schema_auth_sqlite.ts` - `packages/database/src/schema_auth_sqlite.ts`
- `src/db/schema_auth_postgres.ts` - `packages/database/src/schema_auth_postgres.ts`
App-specific tables are manually maintained in the standard Drizzle schema files: App-specific tables are manually maintained in the standard Drizzle schema files:
- `src/db/schema_sqlite.ts` - `packages/database/src/schema_sqlite.ts`
- `src/db/schema_postgres.ts` - `packages/database/src/schema_postgres.ts`
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together. Both sets of schema files are included in the Drizzle generation configs. Runtime migration
execution is owned by `@openreader/database`.
When app schema changes (for example `tts_segments`), keep these in sync: When app schema changes (for example `tts_segments`), keep these in sync:
- `src/db/schema_sqlite.ts` - `packages/database/src/schema_sqlite.ts`
- `src/db/schema_postgres.ts` - `packages/database/src/schema_postgres.ts`
- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json` - `packages/database/migrations/sqlite/*.sql` + `packages/database/migrations/sqlite/meta/_journal.json`
- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json` - `packages/database/migrations/postgres/*.sql` + `packages/database/migrations/postgres/meta/_journal.json`
<Tabs groupId="generate-migration-commands"> <Tabs groupId="generate-migration-commands">
<TabItem value="project-script" label="Project Script" default> <TabItem value="project-script" label="Project Script" default>
@ -130,10 +127,10 @@ pnpm generate
```bash ```bash
# Generate SQLite migrations only (skips Better Auth CLI) # Generate SQLite migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts pnpm exec drizzle-kit generate --config packages/database/drizzle.config.sqlite.ts
# Generate Postgres migrations only (skips Better Auth CLI) # Generate Postgres migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts pnpm exec drizzle-kit generate --config packages/database/drizzle.config.pg.ts
``` ```
:::warning :::warning

View file

@ -106,7 +106,7 @@ For all variables and defaults, see [Environment Variables](../reference/environ
## 4. Database and data migrations ## 4. Database and data migrations
Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic startup migrations do not run there. Vercel deployments do not run the `@openreader/bootstrap` process, so automatic startup migrations do not run there.
- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB. - Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this. - Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.

View file

@ -1,56 +0,0 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import * as dotenv from 'dotenv';
function loadEnvFiles() {
// Approximate Next.js behavior enough for server-side scripts.
// Load .env first, then .env.local (local overrides).
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
if (fs.existsSync(envLocalPath)) {
dotenv.config({ path: envLocalPath, override: true });
}
}
loadEnvFiles();
const extraArgs = process.argv.slice(2);
const hasConfigArg = extraArgs.includes('--config');
const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts';
const configArgs = hasConfigArg ? [] : ['--config', configFile];
// Ensure the docstore directory exists for SQLite migrations.
// drizzle-kit opens the database file directly and will fail if the parent
// directory is missing (e.g. in a fresh CI checkout).
if (!process.env.POSTGRES_URL) {
const dbDir = path.join(process.cwd(), 'docstore');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
}
function resolveDrizzleKitBin() {
const binName = process.platform === 'win32' ? 'drizzle-kit.cmd' : 'drizzle-kit';
const localBin = path.join(process.cwd(), 'node_modules', '.bin', binName);
if (fs.existsSync(localBin)) return localBin;
return 'drizzle-kit';
}
const result = spawnSync(resolveDrizzleKitBin(), ['migrate', ...configArgs, ...extraArgs], {
stdio: 'inherit',
env: process.env,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
canvas: '@napi-rs/canvas', canvas: '@napi-rs/canvas',
}, },
}, },
transpilePackages: [], transpilePackages: ['@openreader/database'],
serverExternalPackages, serverExternalPackages,
outputFileTracingIncludes: { outputFileTracingIncludes: {
'/api/audiobook': [ '/api/audiobook': [

View file

@ -4,12 +4,12 @@
"private": true, "private": true,
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.33.4",
"scripts": { "scripts": {
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw", "dev": "pnpm --filter @openreader/bootstrap start pnpm dev:raw",
"dev:raw": "next dev --turbopack -p 3003", "dev:raw": "next dev --turbopack -p 3003",
"build": "next build", "build": "next build",
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs", "build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
"check:compute-boundary": "node scripts/check-compute-boundary.mjs", "check:compute-boundary": "node scripts/check-compute-boundary.mjs",
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw", "start": "pnpm --filter @openreader/bootstrap start pnpm start:raw",
"start:raw": "next start -p 3003", "start:raw": "next start -p 3003",
"lint": "next lint", "lint": "next lint",
"test": "playwright test", "test": "playwright test",
@ -19,10 +19,10 @@
"compose": "docker compose -f docker/examples/compose.yml up", "compose": "docker compose -f docker/examples/compose.yml up",
"compose:full": "docker compose -f docker/examples/compose.full.yml up", "compose:full": "docker compose -f docker/examples/compose.full.yml up",
"compose:local": "docker compose -f docker/examples/compose.local.yml up --build", "compose:local": "docker compose -f docker/examples/compose.local.yml up --build",
"migrate": "node drizzle/scripts/migrate.mjs", "migrate": "pnpm --filter @openreader/database migrate",
"migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs": "pnpm --filter @openreader/bootstrap migrate-storage",
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "migrate-fs:dry-run": "pnpm --filter @openreader/bootstrap migrate-storage --dry-run true",
"generate": "node drizzle/scripts/generate.mjs", "generate": "node packages/database/scripts/generate.mjs",
"docs:init": "pnpm --dir docs-site install", "docs:init": "pnpm --dir docs-site install",
"docs:dev": "pnpm --dir docs-site start", "docs:dev": "pnpm --dir docs-site start",
"docs:build": "pnpm --dir docs-site build", "docs:build": "pnpm --dir docs-site build",
@ -31,8 +31,8 @@
"docs:clear": "pnpm --dir docs-site clear", "docs:clear": "pnpm --dir docs-site clear",
"compute:dev": "pnpm --filter @openreader/compute-worker dev", "compute:dev": "pnpm --filter @openreader/compute-worker dev",
"compute:start": "pnpm --filter @openreader/compute-worker start", "compute:start": "pnpm --filter @openreader/compute-worker start",
"compute:openapi:generate": "pnpm --dir compute-worker openapi:generate && openapi-typescript compute-worker/openapi.json -o src/lib/server/compute-worker/generated.ts", "compute:openapi:generate": "pnpm --dir packages/compute-worker openapi:generate && openapi-typescript packages/compute-worker/openapi.json -o src/lib/server/compute-worker/generated.ts",
"compute:openapi:check": "pnpm compute:openapi:generate && git diff --exit-code -- compute-worker/openapi.json src/lib/server/compute-worker/generated.ts", "compute:openapi:check": "pnpm compute:openapi:generate && git diff --exit-code -- packages/compute-worker/openapi.json src/lib/server/compute-worker/generated.ts",
"lint:route-errors": "node scripts/check-route-error-responses.mjs" "lint:route-errors": "node scripts/check-route-error-responses.mjs"
}, },
"dependencies": { "dependencies": {
@ -42,20 +42,19 @@
"@microsoft/antissrf": "^1.0.0", "@microsoft/antissrf": "^1.0.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@napi-rs/canvas": "^0.1.100", "@napi-rs/canvas": "^0.1.100",
"@openreader/database": "workspace:*",
"@speech-sdk/core": "^0.15.0", "@speech-sdk/core": "^0.15.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"better-auth": "^1.6.14", "better-auth": "^1.6.14",
"better-sqlite3": "^12.10.0",
"cmpstr": "^3.3.0", "cmpstr": "^3.3.0",
"compromise": "^14.15.1", "compromise": "^14.15.1",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"dexie": "^4.4.3", "dexie": "^4.4.3",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"fast-xml-parser": "^5.8.0", "fast-xml-parser": "^5.8.0",
@ -67,7 +66,6 @@
"next": "^15.5.19", "next": "^15.5.19",
"openai": "^6.41.0", "openai": "^6.41.0",
"pdfjs-dist": "4.8.69", "pdfjs-dist": "4.8.69",
"pg": "^8.21.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"react": "^19.2.7", "react": "^19.2.7",
@ -98,6 +96,8 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"better-sqlite3": "^12.10.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^15.5.19", "eslint-config-next": "^15.5.19",
"postcss": "^8.5.15", "postcss": "^8.5.15",

View file

@ -0,0 +1,25 @@
{
"name": "@openreader/bootstrap",
"version": "0.0.0",
"private": true,
"type": "module",
"files": [
"src"
],
"bin": {
"openreader": "./src/cli.mjs",
"openreader-migrate-storage": "./src/storage-migration.mjs"
},
"scripts": {
"start": "node src/cli.mjs --",
"migrate-storage": "node src/storage-migration.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1061.0",
"@openreader/database": "workspace:*",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
"ffmpeg-static": "^5.3.0",
"pg": "^8.21.0"
}
}

View file

@ -3,11 +3,23 @@ import { spawn, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { once } from 'node:events'; import { once } from 'node:events';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises'; import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import { runMigrations } from '@openreader/database/migrate';
import { hasNatsBinary } from './embedded-nats.mjs';
import {
detectHostForDefaultEndpoint,
hasWeedBinary,
isRunningInDocker,
loopbackS3Endpoint,
parseS3Endpoint,
parseUrlHost,
waitForEndpoint,
} from './embedded-seaweedfs.mjs';
import { resolveEmbeddedWorkerLaunch } from './embedded-worker.mjs';
function loadEnvFiles() { function loadEnvFiles() {
const cwd = process.cwd(); const cwd = process.cwd();
@ -49,83 +61,6 @@ function requireAuthEnv(env) {
} }
} }
function isPrivateIPv4(address) {
if (!address) return false;
if (address.startsWith('10.')) return true;
if (address.startsWith('192.168.')) return true;
const m = /^172\.(\d+)\./.exec(address);
if (m) {
const second = Number.parseInt(m[1], 10);
if (second >= 16 && second <= 31) return true;
}
return false;
}
function detectHostForDefaultEndpoint() {
const interfaces = os.networkInterfaces();
const ipv4 = [];
for (const entries of Object.values(interfaces)) {
for (const entry of entries || []) {
if (!entry) continue;
const family = typeof entry.family === 'string' ? entry.family : String(entry.family);
if (family !== 'IPv4') continue;
if (entry.internal) continue;
ipv4.push(entry.address);
}
}
const privateAddr = ipv4.find(isPrivateIPv4);
if (privateAddr) return privateAddr;
if (ipv4[0]) return ipv4[0];
return '127.0.0.1';
}
function parseS3Endpoint(endpoint) {
let url;
try {
url = new URL(endpoint);
} catch {
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
}
if (!url.hostname) {
throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
}
const port = Number.parseInt(url.port || '8333', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
}
return {
hostname: url.hostname,
port,
normalized: `${url.protocol}//${url.hostname}:${port}`,
};
}
function loopbackS3Endpoint(endpoint) {
const parsed = parseS3Endpoint(endpoint);
const url = new URL(parsed.normalized);
return `${url.protocol}//127.0.0.1:${parsed.port}`;
}
function parseUrlHost(urlValue, fieldName) {
let url;
try {
url = new URL(urlValue);
} catch {
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
}
if (!url.hostname) {
throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
}
return url.hostname;
}
function parseCommandFromArgs(argv) { function parseCommandFromArgs(argv) {
const marker = argv.indexOf('--'); const marker = argv.indexOf('--');
if (marker >= 0) return argv.slice(marker + 1); if (marker >= 0) return argv.slice(marker + 1);
@ -143,51 +78,6 @@ function forwardChildStream(stream, target) {
}; };
} }
function hasWeedBinary() {
const probe = spawnSync('weed', ['version'], { stdio: 'ignore' });
if (probe.error) return false;
return true;
}
function hasNatsBinary() {
const probe = spawnSync('nats-server', ['-v'], { stdio: 'ignore' });
if (probe.error) return false;
return true;
}
async function waitForEndpoint(url, timeoutSeconds) {
const waitMs = Math.max(1, timeoutSeconds) * 1000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(url, { method: 'GET', signal: controller.signal });
if (res) return;
} catch {
// retry
} finally {
clearTimeout(timeoutId);
}
await delay(1000);
}
throw new Error(`Embedded weed mini did not become ready at ${url} within ${timeoutSeconds}s.`);
}
function isRunningInDocker() {
if (process.platform !== 'linux') return false;
if (fs.existsSync('/.dockerenv')) return true;
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
return /(docker|containerd|kubepods|podman)/i.test(cgroup);
} catch {
return false;
}
}
function spawnMainCommand(command, env) { function spawnMainCommand(command, env) {
const [cmd, ...args] = command; const [cmd, ...args] = command;
const child = spawn(cmd, args, { const child = spawn(cmd, args, {
@ -219,53 +109,13 @@ function spawnMainCommand(command, env) {
return { child, exitPromise }; return { child, exitPromise };
} }
function resolveEmbeddedWorkerLaunch() { async function runDbMigrations(env) {
const candidateDirs = [
path.join(process.cwd(), 'embedded-compute-worker'),
path.join(process.cwd(), 'compute-worker'),
];
for (const workerDir of candidateDirs) {
const serverEntry = path.join(workerDir, 'src', 'server.ts');
if (!fs.existsSync(serverEntry)) continue;
return {
cmd: process.execPath,
args: ['--import', 'tsx', 'src/server.ts'],
cwd: workerDir,
};
}
throw new Error(
'Could not find an embedded compute worker runtime. '
+ 'Include embedded-compute-worker/src/server.ts in the runtime image or keep compute-worker available locally.',
);
}
function runDbMigrations(env) {
const migrateScript = path.join(process.cwd(), 'drizzle', 'scripts', 'migrate.mjs');
if (!fs.existsSync(migrateScript)) {
throw new Error(`Could not find migration script at ${migrateScript}`);
}
console.log('Running database migrations...'); console.log('Running database migrations...');
const migration = spawnSync(process.execPath, [migrateScript], { await runMigrations({ cwd: process.cwd(), env });
env,
stdio: 'inherit',
});
if (migration.error) {
throw migration.error;
}
if (typeof migration.status === 'number' && migration.status !== 0) {
throw new Error(`Database migrations failed with exit code ${migration.status}.`);
}
} }
function runStorageMigrations(env) { function runStorageMigrations(env) {
const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs'); const migrateScript = fileURLToPath(new URL('./storage-migration.mjs', import.meta.url));
if (!fs.existsSync(migrateScript)) {
throw new Error(`Could not find storage migration script at ${migrateScript}`);
}
console.log('Running storage migrations (v2)...'); console.log('Running storage migrations (v2)...');
const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], { const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], {
@ -335,7 +185,7 @@ async function main() {
const command = parseCommandFromArgs(process.argv.slice(2)); const command = parseCommandFromArgs(process.argv.slice(2));
if (command.length === 0) { if (command.length === 0) {
console.error('Usage: node scripts/openreader-entrypoint.mjs -- <command> [args]'); console.error('Usage: openreader -- <command> [args]');
process.exit(2); process.exit(2);
} }
@ -424,7 +274,7 @@ async function main() {
try { try {
const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true); const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true);
if (shouldRunDbMigrations) { if (shouldRunDbMigrations) {
runDbMigrations(runtimeEnv); await runDbMigrations(runtimeEnv);
} }
if (useEmbeddedWeed) { if (useEmbeddedWeed) {
@ -482,7 +332,7 @@ async function main() {
console.log('Starting embedded SeaweedFS weed mini...'); console.log('Starting embedded SeaweedFS weed mini...');
launchWeed(runtimeEnv.S3_ENDPOINT); launchWeed(runtimeEnv.S3_ENDPOINT);
const startupEndpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT); const startupEndpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT);
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout); await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout, 'Embedded SeaweedFS');
console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`); console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`);
} }
@ -555,7 +405,7 @@ async function main() {
console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`); console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`);
scheduleFatalShutdown('nats-server', null, null, 'failed to start'); scheduleFatalShutdown('nats-server', null, null, 'failed to start');
}); });
await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20); await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20, 'Embedded nats-server');
console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`); console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`);
console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`); console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`);
@ -589,7 +439,7 @@ async function main() {
console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`); console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`);
scheduleFatalShutdown('compute-worker', null, null, 'failed to start'); scheduleFatalShutdown('compute-worker', null, null, 'failed to start');
}); });
await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30); await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30, 'Embedded compute-worker');
console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`); console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`);
} else if (!runtimeEnv.COMPUTE_WORKER_URL?.trim() || !runtimeEnv.COMPUTE_WORKER_TOKEN?.trim()) { } else if (!runtimeEnv.COMPUTE_WORKER_URL?.trim() || !runtimeEnv.COMPUTE_WORKER_TOKEN?.trim()) {
throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.'); throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.');

View file

@ -0,0 +1,5 @@
import { spawnSync } from 'node:child_process';
export function hasNatsBinary() {
return !spawnSync('nats-server', ['-v'], { stdio: 'ignore' }).error;
}

View file

@ -0,0 +1,95 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
function isPrivateIPv4(address) {
if (!address) return false;
if (address.startsWith('10.')) return true;
if (address.startsWith('192.168.')) return true;
const match = /^172\.(\d+)\./.exec(address);
if (!match) return false;
const second = Number.parseInt(match[1], 10);
return second >= 16 && second <= 31;
}
export function hasWeedBinary() {
return !spawnSync('weed', ['version'], { stdio: 'ignore' }).error;
}
export function detectHostForDefaultEndpoint() {
const ipv4 = Object.values(os.networkInterfaces())
.flatMap((entries) => entries || [])
.filter((entry) => entry && !entry.internal && String(entry.family) === 'IPv4')
.map((entry) => entry.address);
return ipv4.find(isPrivateIPv4) || ipv4[0] || '127.0.0.1';
}
export function parseS3Endpoint(endpoint) {
let url;
try {
url = new URL(endpoint);
} catch {
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
}
if (!url.hostname) throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
const port = Number.parseInt(url.port || '8333', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
}
return {
hostname: url.hostname,
port,
normalized: `${url.protocol}//${url.hostname}:${port}`,
};
}
export function loopbackS3Endpoint(endpoint) {
const parsed = parseS3Endpoint(endpoint);
const url = new URL(parsed.normalized);
return `${url.protocol}//127.0.0.1:${parsed.port}`;
}
export function parseUrlHost(urlValue, fieldName) {
let url;
try {
url = new URL(urlValue);
} catch {
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
}
if (!url.hostname) throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
return url.hostname;
}
export async function waitForEndpoint(url, timeoutSeconds, serviceName = 'service') {
const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000;
while (Date.now() < deadline) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(url, { method: 'GET', signal: controller.signal });
if (response) return;
} catch {
// Retry until the startup deadline.
} finally {
clearTimeout(timeoutId);
}
await delay(1000);
}
throw new Error(`${serviceName} did not become ready at ${url} within ${timeoutSeconds}s.`);
}
export function isRunningInDocker() {
if (process.platform !== 'linux') return false;
if (fs.existsSync('/.dockerenv')) return true;
try {
return /(docker|containerd|kubepods|podman)/i.test(fs.readFileSync('/proc/1/cgroup', 'utf8'));
} catch {
return false;
}
}

View file

@ -0,0 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const bootstrapDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function resolveEmbeddedWorkerLaunch() {
const candidateDirs = [
path.resolve(bootstrapDir, '..', 'embedded-compute-worker'),
path.resolve(bootstrapDir, '..', 'compute-worker'),
path.join(process.cwd(), 'embedded-compute-worker'),
path.join(process.cwd(), 'packages', 'compute-worker'),
path.join(process.cwd(), 'compute-worker'),
];
for (const workerDir of candidateDirs) {
if (!fs.existsSync(path.join(workerDir, 'src', 'server.ts'))) continue;
return {
cmd: process.execPath,
args: ['--import', 'tsx', 'src/server.ts'],
cwd: workerDir,
};
}
throw new Error(
'Could not find an embedded compute worker runtime. '
+ 'Include embedded-compute-worker/src/server.ts in the runtime image or keep packages/compute-worker available locally.',
);
}

View file

@ -1,19 +1,17 @@
FROM node:lts AS deploy-stage FROM node:lts AS deploy-stage
RUN npm install -g pnpm@11.1.2 RUN npm install -g pnpm@10.33.4
WORKDIR /workspace WORKDIR /workspace
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY compute-worker/package.json compute-worker/package.json COPY packages/compute-worker/package.json packages/compute-worker/package.json
COPY compute-worker compute-worker COPY packages/compute-worker packages/compute-worker
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
FROM node:lts FROM node:lts
RUN npm install -g pnpm@11.1.2
WORKDIR /workspace WORKDIR /workspace
COPY --from=deploy-stage /opt/compute-worker ./ COPY --from=deploy-stage /opt/compute-worker ./

View file

@ -1,4 +1,4 @@
import type { BaseDocument } from '../../../../../src/types/documents'; import type { BaseDocument } from '../../../../../../src/types/documents';
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/api/types'; import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/api/types';
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument { export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {

View file

@ -45,6 +45,17 @@ class FakeKvStore implements KvStoreLike {
this.revision += 1; this.revision += 1;
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision }); this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
} }
async keys(filter?: string | string[]): Promise<AsyncIterable<string>> {
const keys = Array.from(this.data.keys());
return {
async *[Symbol.asyncIterator]() {
for (const key of keys) {
yield key;
}
}
};
}
} }
class FakeJetStream { class FakeJetStream {

View file

@ -24,8 +24,8 @@ describe('orphan recovery', () => {
}); });
const firstSweep = await recoverOrphanedOperations({ const firstSweep = await recoverOrphanedOperations({
operationStateStore: fake.deps.operationStateStore, operationStateStore: fake.deps.operationStateStore!,
orchestrator: fake.deps.orchestrator, orchestrator: fake.deps.orchestrator!,
whisperTimeoutMs: 30_000, whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000, pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000, opStaleMs: 1_800_000,
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
vi.advanceTimersByTime(31_000); vi.advanceTimersByTime(31_000);
const secondSweep = await recoverOrphanedOperations({ const secondSweep = await recoverOrphanedOperations({
operationStateStore: fake.deps.operationStateStore, operationStateStore: fake.deps.operationStateStore!,
orchestrator: fake.deps.orchestrator, orchestrator: fake.deps.orchestrator!,
whisperTimeoutMs: 30_000, whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000, pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000, opStaleMs: 1_800_000,

View file

@ -1,5 +1,5 @@
{ {
"extends": "../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
}, },

View file

@ -0,0 +1,9 @@
#!/usr/bin/env node
import { runMigrations } from '../src/migrate.mjs';
try {
await runMigrations();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}

View file

@ -10,8 +10,8 @@ if (!url) {
} }
export default { export default {
schema: ['./src/db/schema_postgres.ts', './src/db/schema_auth_postgres.ts'], schema: ['./packages/database/src/schema_postgres.ts', './packages/database/src/schema_auth_postgres.ts'],
out: './drizzle/postgres', out: './packages/database/migrations/postgres',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
url, url,

View file

@ -4,8 +4,8 @@ import * as dotenv from 'dotenv';
dotenv.config(); dotenv.config();
export default { export default {
schema: ['./src/db/schema_sqlite.ts', './src/db/schema_auth_sqlite.ts'], schema: ['./packages/database/src/schema_sqlite.ts', './packages/database/src/schema_auth_sqlite.ts'],
out: './drizzle/sqlite', out: './packages/database/migrations/sqlite',
dialect: 'sqlite', dialect: 'sqlite',
dbCredentials: { dbCredentials: {
url: 'docstore/sqlite3.db', url: 'docstore/sqlite3.db',

Some files were not shown because too many files have changed in this diff Show more