extract authd's SQLite migration system into a standalone npm package #16

Closed
opened 2026-05-13 19:02:01 +00:00 by brendan · 0 comments
Owner

Originally filed: 2026-05-11 in ~/features.md, block #2.
Cross-project companion issues: brendan/sqlite-migrate, brendan/authd, brendan/buchinese, brendan/inventory, brendan/dashcam, brendan/movement

2026-05-11 — cross-project: extract authd's SQLite migration system into a standalone npm package installable via direct Gitea URL, make the repo public, then adopt it in every project that uses SQLite (buchinese, inventory, nanodrop, movement, dashcam, and authd itself dogfoods). Replaces six independent hand-rolled applySchema(db) blobs with one versioned, checksum-verified, runner-tested implementation.

Motivation. authd PR #14 (merge_commit ced21ab4d16c28a6b5fafa9072b0ae8207ba4bfd, merged 2026-05-11) landed a real migration system: numbered .sql files in src/db/migrations/, a schema_migrations tracking table, sha256 checksum verification, idempotent re-apply, genesis-stamping for pre-existing prod DBs, and three CLI scripts (db:migrate / db:status / db:stamp). The runner lives in src/db/migrate.ts (178 LOC, exports applyMigrations, listMigrations, readAppliedRows, stampMigration) and is exercised by tests/db/migrate.test.ts (270 LOC, 9 specs covering fresh-apply / idempotent / checksum-mismatch / genesis-stamp / partway-failure rollback).

Meanwhile, every other SQLite-using project still ships a single applySchema(db) function that runs CREATE TABLE IF NOT EXISTS for every table on every boot and does ad-hoc ALTER patches when schema needs to evolve (e.g. src/db/schema.ts:111-122 in buchinese for the sessions.expires_at retrofit). This is fine for a fresh DB but rots fast: there's no way to add a new column to an existing table without writing the same idempotent-probe pattern by hand each time, and there's no record of which schema state any given prod DB is at. The next migration in any of these projects (e.g. the buchinese article user-scoping feature filed below) would re-invent the same probe-and-ALTER pattern again.

One package, six consumers — much better than six divergent copies.

Inventory of consumers.

project schema entry point tables (approx) first table for genesis probe
authd src/db/schema.ts applySchema → already replaced by applyMigrations in PR #14, but currently inlines the runner under src/db/migrate.ts. After this feature, authd imports the runner from the package and deletes its inline copy. users, login_attempts, oauth_, mfa_ users
buchinese src/db/schema.ts:109-123 applySchema sessions, oauth_pending, articles, article_tags, article_tokens, article_sentences, flashcards, flashcard_review_history sessions
inventory src/db/schema.ts applySchema (audit during implementation) TBD
nanodrop src/db/schema.ts:3 initDb users, files, login_attempts users
dashcam src/db/schema.ts:6 initDb sessions, oauth_pending, uploads, processing_queue, clips sessions
movement src/server/db/client.ts (no central applySchema; DDL is split across users.ts, login-attempts.ts, etc.) users, login_attempts, … users

Each project's genesis migration (0001_init.sql) just captures whatever the current applySchema block emits, byte-for-byte; the runner then stamps it as applied on the first migrate-run against an existing prod DB and applies zero rows.

Package shape.

  • Repo: gitea.bchen.dev/brendan/sqlite-migrate (must be public so npm install from a non-authenticated context — e.g. a Dockerfile RUN npm ci inside a public CI runner — can fetch the tarball without injecting credentials). The repo is created fresh, not forked; it doesn't carry any of authd's history.
  • Package name: @bchen/sqlite-migrate (scoped name) or unscoped bchen-sqlite-migrate — implementer picks based on whichever installs cleanly across consumers; scoped names with custom registries get fussy when there's no .npmrc, so unscoped is the safer default for direct-URL installs. Document the chosen name in the package README.
  • License: MIT, to keep the public-repo decision frictionless.
  • Install form (consumers' package.json dependency):
    "bchen-sqlite-migrate": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0"
    
    Use HTTPS + a version tag (not SSH, because Docker builds inside CI don't have SSH keys). Public repo means no token needed in the URL. Pin to a tag (#v0.1.0), not a branch, so consumers don't silently pick up breaking changes on the next npm install. Tags follow semver.
  • Exports (package.json "exports" map; ESM-only, matching the existing TS code):
    • "." → the runner API: applyMigrations, listMigrations, readAppliedRows, stampMigration, plus the public types (MigrationFile, MigrationRow, MigrationSummary, ApplyOptions).
    • "./cli" → optional helper that consumers can wire from their own tsx src/scripts/db-migrate.ts shim — a single runMigrateCli({ openDb, migrationsDir }) function so each consumer just writes a 5-line script that injects its config-loading function. The package does NOT include a default bin/ entry because the DB path / config loader differs per project (authd's loadConfig() ≠ buchinese's, etc.).
  • Build: ship pre-compiled .js + .d.ts so consumers don't need to wire tsx for the package itself; their consumer code can still be .ts. Use the existing tsc --emitDeclarationOnly + a tiny esbuild step to bundle to dist/. The "files" field in package.json limits the published surface to dist/, README.md, LICENSE.

Parameterization. The runner today hard-codes one thing that has to become configurable for cross-project use:

  • GENESIS_PROBE_TABLE = 'users' (src/db/migrate.ts:34 in authd). Replace with an ApplyOptions.genesisProbeTable?: string parameter (default 'users', to preserve authd's behavior when authd consumes the package). Each consumer passes its own first-table name from the table above. Without this, the runner would refuse to stamp a buchinese DB (no users table) and would try to re-run 0001_init.sql on top of an existing populated DB → instant failure on CREATE TABLE for tables that already exist.

The rest of the runner stays as-is — version regex, checksum behavior, transaction-per-migration, the rollback-on-failure semantics, and the WARN log line on genesis-stamp all remain identical.

Scope.

  1. New repo + package

    • Create gitea.bchen.dev/brendan/sqlite-migrate (public). Use tea repo create --name sqlite-migrate --owner brendan --init.
    • Lift src/db/migrate.ts + tests/db/migrate.test.ts from authd; add genesisProbeTable option; preserve the 9 existing test specs and add three new ones:
      • probe-table-not-present + stampGenesis=false → first migration runs normally (no stamp).
      • genesisProbeTable=undefined defaults to 'users' (authd back-compat).
      • genesisProbeTable='sessions' correctly stamps a buchinese-shaped DB.
    • Wire runMigrateCli({ openDb, migrationsDir, stampGenesis }) thin wrapper extracted from authd's src/scripts/db-migrate.ts (so consumers don't re-copy the 25-line CLI shim).
    • Build pipeline: npm run builddist/ with .js + .d.ts; npm test runs vitest.
    • package.json with the chosen name, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", peer-dep on better-sqlite3 (do NOT bundle it; consumers already have it pinned).
    • Tag v0.1.0 after CI is green.
  2. Adopt in authd (dogfood — first consumer, lowest risk because it already has the runner inlined)

    • npm i bchen-sqlite-migrate@git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0
    • Delete src/db/migrate.ts (now in the package).
    • Update src/scripts/_db-cli.ts + the three db-*.ts scripts to import from the package.
    • Update tests/db/migrate.test.ts to import from the package OR delete it (package owns those tests now). Recommended: keep a thin authd-specific test that asserts 0001_init.sql is byte-stable, since checksum drift would break prod migrations.
    • Verify npm run db:migrate against a fresh tempdir and against a pre-existing dev DB → both behave identically to pre-extraction.
  3. Adopt in buchinese, inventory, nanodrop, dashcam, movement (one PR per project)

    • Add the package dependency.
    • Create src/db/migrations/0001_init.sql containing exactly the current applySchema body (or, for movement, the union of the split DDLs).
      • For buchinese: also bake in the sessions.expires_at ALTER + backfill (lines 111-122 of src/db/schema.ts) into 0001_init.sql — by the time this migration system lands, prod has already been through that one-shot, so the genesis stamp will skip executing 0001_init.sql on existing DBs anyway.
    • Replace applySchema(db) (or the per-project equivalent) with a call to applyMigrations(db, migrationsDir, { stampGenesis: process.env.DB_MIGRATIONS_STAMP_GENESIS === '1', genesisProbeTable: '<project first table>' }).
    • Add db:migrate, db:status, db:stamp npm scripts (copy from authd's package.json).
    • Per-project sanity test: spin up an empty tempdir DB → applyMigrations → assert every expected table exists. And: pre-seed a DB with the current tables + no schema_migrationsapplyMigrations → assert it stamps 0001 and applies nothing.
  4. Per-deploy bootstrap doc (one-line in each project's README): "first prod boot after this PR should show WARN genesis-stamp: marked 0001_init as applied without executing followed by migrations: 1 applied, 0 pending in the container log. If you see CREATE TABLE … already exists instead, the genesis probe table is wrong — file a bug."

  5. Cross-cut with the buchinese article-scoping feature filed below. Once this package is adopted in buchinese, the article user-scoping feature filed in this same features.md block can ship its schema delta as 0002_articles_authd_sub.sql instead of inventing a one-off ad-hoc ALTER pattern. The implementer should pick up this package adoption before the article-scoping migration so the new convention is in place.

Out of scope.

  • Publishing to the public npm registry. The user explicitly asked for direct Gitea URL installs; npm publish is a separate decision and adds release-key management.
  • Down-migrations / rollbacks. The authd implementation is forward-only on purpose (MigrationSummary has no rolled_back field). Adding rollback support adds operational complexity (which down do you run, how do you handle data loss) and isn't needed for any current project.
  • Multi-statement transactional safety beyond what better-sqlite3's db.exec() already provides. The runner wraps each migration in db.transaction(...) — if a migration mixes DDL and DML and the DML fails, the whole migration rolls back. That's enough.
  • Schema diffing or auto-generation. Each migration is hand-authored SQL. No ORM, no diff tool.
  • Postgres / MySQL support. SQLite-only, matching every consumer's current reality. If a future project lands on Postgres it'll use its own ecosystem (node-pg-migrate, drizzle-kit, etc.) — no reason to retrofit one runner across dialects.
  • A web UI for migration status. db:status is a CLI that prints a table.

Acceptance.

  • gitea.bchen.dev/brendan/sqlite-migrate is public, tagged v0.1.0, with a green CI run on the tag.
  • npm i git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0 works from a fresh shell with no Gitea credentials (this is the public-repo proof).
  • authd's main branch consumes the package; its inline src/db/migrate.ts is deleted; all existing authd tests still pass; npm run db:migrate against a pre-existing prod-shaped DB produces the same WARN-stamp output as before extraction.
  • All five other SQLite projects have adopted the package, each with their own 0001_init.sql. For each project, a fresh DB and a pre-existing prod-shaped DB both reach the same final schema state under npm run db:migrate.
  • Document the install command + the DB_MIGRATIONS_STAMP_GENESIS env var in the package README, with a section per consumer project showing the exact applyMigrations call signature (including the right genesisProbeTable).
> **Originally filed:** 2026-05-11 in ~/features.md, block #2. > **Cross-project companion issues:** brendan/sqlite-migrate, brendan/authd, brendan/buchinese, brendan/inventory, brendan/dashcam, brendan/movement **2026-05-11 — cross-project: extract authd's SQLite migration system into a standalone npm package installable via direct Gitea URL, make the repo public, then adopt it in every project that uses SQLite (buchinese, inventory, nanodrop, movement, dashcam, and authd itself dogfoods). Replaces six independent hand-rolled `applySchema(db)` blobs with one versioned, checksum-verified, runner-tested implementation.** <!-- Phase 1 RESOLVED 2026-05-12 — package v0.1.0 shipped. Repo gitea.bchen.dev/brendan/sqlite-migrate (public, MIT). PR https://gitea.bchen.dev/brendan/sqlite-migrate/pulls/1 (merge_commit 7dbce6637c7ababc72da7747513989f3f83b3571). Tag v0.1.0 pushed (annotated on merge commit). Package name `bchen-sqlite-migrate` (unscoped). Install URL: `git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0`. Adds the one required parameterization `ApplyOptions.genesisProbeTable?: string` (default `'users'`). 15/15 vitest specs green (9 lifted from authd + 3 new probe-table specs + 3 new CLI specs). dist/ committed (pre-compiled JS+d.ts for direct-URL installs); CI workflow (.gitea/workflows/ci.yml) gates typecheck+build+test+dist-drift. Phase 2 (authd dogfood) + Phase 3 (adopt in buchinese, inventory, nanodrop, dashcam, movement — one PR each) remain actionable. --> <!-- Phase 2 RESOLVED 2026-05-12 — authd dogfood landed. PR https://gitea.bchen.dev/brendan/authd/pulls/15 (merge_commit 0095f105c94fc72f4a5a2a15fec0e19a3513d9ec). REVIEWED_SHA=10d448e888766e0578a5fae5da60aa367346f182 (single feat commit; refactor pass noop). authd now imports `applyMigrations`/`listMigrations`/`readAppliedRows`/`stampMigration` from `bchen-sqlite-migrate@v0.1.0` (git+https URL, commit-SHA pinned in lockfile). `src/db/migrate.ts` (178 LOC) deleted; `tests/db/migrate.test.ts` (270 LOC, 9 specs) deleted — package owns those tests now. Added `tests/db/migrations-byte-stable.test.ts` pinning sha256 of `src/db/migrations/0001_init.sql` (`e34a7334982b9994ef1f9169b12f96ce3c649106ad222b04f6eb1c7e2adcd673`) as the authd-specific guard against accidental in-place edits. Checksum input pipeline verified bit-identical between package's `dist/migrate.js` and the deleted inline runner (UTF-8 `readFileSync` → `createHash('sha256').update(body).digest('hex')`) — prod `schema_migrations` rows won't see drift. 210/210 vitest specs green (was 218; -9 + 1). tsc --noEmit + npm run build clean. ENV_VAR_GATE clean (zero new/renamed/removed vars; existing optional `DB_MIGRATIONS_STAMP_GENESIS` continues to flow through unchanged). SECURITY None — pure dep swap, lockfile commit-SHA pin, self-hosted Gitea same trust boundary, no new routes/auth/secrets/env. Lockfile-verified: `npm ci` on clean `node_modules` (sandbox-side); `docker build .` deferred to first prod deploy (docker daemon socket not reachable from sandbox). Auto-merge via Gitea API POST /pulls (PR #15) + POST /merge with `Do=merge head_commit_id=10d448e...`; merge commit `0095f105...`; feature branch deleted post-merge. After deploy expect boot logs to show the same `migrations: 1 applied, 0 pending` they did pre-swap. Phase 3 (adopt in buchinese, inventory, nanodrop, dashcam, movement — one PR each) remains actionable; the buchinese cross-cut (user-scoped articles feature below) is the natural first port. --> <!-- Phase 3 buchinese RESOLVED 2026-05-12 — first non-authd consumer adopted. PR https://gitea.bchen.dev/brendan/buchinese/pulls/14 (merge_commit 19811d73528c79dcfb4f001c0c98f3db486c3cc8). REVIEWED_SHA=c10d43677c480441fa1545795ec464cb88257b1a (feat 9f39bd6 + refactor c10d436). buchinese now imports `applyMigrations` from `bchen-sqlite-migrate@v0.1.0` (git+https URL pinned to v0.1.0 tag = commit `7dbce6637c…`). `src/db/schema.ts` rewritten ~25 LOC: `applySchema(db)` thin wrapper (passes `genesisProbeTable: 'sessions'`, no logger — keeps 5 existing test callers silent) + `initDb(dbPath)` that reads `DB_MIGRATIONS_STAMP_GENESIS` env and applies migrations with verbose summary. `SCHEMA_DDL` template literal + `SESSION_TTL_SECONDS` import + one-shot ALTER+UPDATE for legacy `sessions.expires_at` retrofit all removed (the ALTER did its job in prod long ago; the column is in the CREATE statement now). New `src/db/migrations/0001_init.sql` captures the prior SCHEMA_DDL byte-for-byte (102 lines, 8 tables + 9 indexes); sha256 `1d09e282a54a4c84cc7cb3c3ecf08b447ef3fd1e5b5a759532dc87e822192558` pinned by `tests/unit/migrations-byte-stable.test.ts`. Added `src/scripts/{_db-cli,db-migrate,db-status,db-stamp}.ts` mirroring authd. Refactor commit c10d436 exports `MIGRATIONS_DIR` from `src/db/schema.ts` and imports it in `_db-cli.ts` — byte-identical resolved path, single source of truth. 176/176 vitest specs green. tsc --noEmit + npm run build clean. ENV_VAR_GATE clean (`DB_MIGRATIONS_STAMP_GENESIS` only new read, optional default-off). SECURITY None — pure dep swap, no new HTTP/auth/secrets surface, static `.sql` source for all new SQL. Lockfile-verified: `rm -rf node_modules package-lock.json && npm install` then `rm -rf node_modules && npm ci` both exit 0 (sandbox-side); `docker build .` deferred to first prod deploy (no docker daemon in sandbox, same caveat as authd PR #15). Auto-merge via Gitea API POST /pulls (PR #14 mergeable=true head_sha=c10d436 bit-for-bit match REVIEWED_SHA) + POST /merge with `Do=merge head_commit_id=c10d436…`; merge_commit `19811d73…`; feature branch `feat/adopt-sqlite-migrate` deleted post-merge; worktree cleaned. First prod boot will log `WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'sessions' table)` then `migrations: 1 applied, 0 pending`; subsequent boots `0 applied, 0 pending`. Phase 3 remaining: inventory, nanodrop, dashcam, movement — one PR each. The user-scoped-articles feature filed immediately below is now unblocked from a migration-system perspective and can ship its schema delta as `0002_articles_authd_sub.sql` against the just-landed runner. --> <!-- Phase 3 nanodrop RESOLVED 2026-05-12 — second non-authd consumer adopted. PR https://gitea.bchen.dev/brendan/nanodrop/pulls/9 (merge_commit 83a128f917086b18c43e803f80916d0166a58e1c). REVIEWED_SHA=436f7417be7f4841ca189859f0e72b505d0896cc (single feat commit; refactor pass noop). nanodrop now imports `applyMigrations` from `bchen-sqlite-migrate@v0.1.0` (git+https URL pinned to v0.1.0 tag = commit `7dbce6637c…`). `src/db/schema.ts` rewritten: `applySchema(db)` thin wrapper (passes `genesisProbeTable: 'users'` — matches package default but explicit per buchinese precedent) + `initDb(dbPath)` reads `DB_MIGRATIONS_STAMP_GENESIS` env and applies migrations. Inline `db.exec(...)` block removed; new `src/db/migrations/0001_init.sql` captures the prior DDL byte-for-byte (4 statements: users, files, login_attempts, idx_login_attempts_locked_until); sha256 pinned by `tests/unit/migrations-byte-stable.test.ts`. Added `src/scripts/{_db-cli,db-migrate,db-status,db-stamp}.ts` mirroring authd/buchinese. 131/131 vitest specs green (was 130; +1 new byte-stable). tsc --noEmit + npm run build clean. ENV_VAR_GATE clean (`DB_MIGRATIONS_STAMP_GENESIS` only new read, optional default-off, matches authd/buchinese precedent — not env-blocked). SECURITY None — pure dep swap, no new HTTP/auth/secrets surface, applyMigrations execs static repo-content SQL, FILENAME_RE in package defeats path traversal. Lockfile-verified: `npm ci` on clean `node_modules` (sandbox-side, exit 0); `docker build .` deferred to first prod deploy. Auto-merge via Gitea API POST /pulls (PR #9 opened mergeable=true head.sha=436f7417 bit-for-bit match REVIEWED_SHA, no SHA drift) + POST /merge with `Do=merge head_commit_id=436f7417…`; merge_commit `83a128f9…`; feature branch `feat/adopt-sqlite-migrate` deleted post-merge. First prod boot will log `WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'users' table)` then `migrations: 1 applied, 0 pending`; subsequent boots `0 applied, 0 pending`. Phase 3 remaining: inventory, dashcam, movement — one PR each. --> <!-- Phase 3 dashcam RESOLVED 2026-05-12 — fourth non-authd consumer adopted (Phase 3 4-of-5). PR https://gitea.bchen.dev/brendan/dashcam/pulls/5 (merge_commit 4a9b2f68287ea1f9fb565ffd77aee806fc07143a). REVIEWED_SHA=fb53ab84144a4eb7e466313149a8f1cbd82ecd4c (feat 091f487 + refactor fb53ab8). dashcam now imports `applyMigrations` from `bchen-sqlite-migrate@v0.1.0` (git+https URL pinned to v0.1.0 tag = commit `7dbce6637c…` resolved in package-lock.json). `src/db/schema.ts` rewritten (99 → 28 LOC): drops the inline `db.exec(...)` template literal + the `expires_at` ALTER/UPDATE retrofit (dead code on prod since first deploy — column already in CREATE TABLE statement, all prod rows have non-zero `expires_at`). `initDb(dbPath)` now calls `applyMigrations(db, MIGRATIONS_DIR, { stampGenesis, genesisProbeTable: 'sessions', logger })`. Note: no `applySchema` wrapper exported (grep confirms zero callsites in src/tests — dashcam tests all use `initDb(':memory:')` directly, unlike inventory/buchinese which had test callsites needing the wrapper). New `src/db/migrations/0001_init.sql` captures the prior DDL byte-for-byte (5 tables — sessions, oauth_pending, uploads, processing_queue, clips — plus 5 indexes; sha256 `9d39265e417d41739380ac930bd62ca43b48ea3c6308c59520174eb483e17e30` pinned by `tests/unit/migrations-byte-stable.test.ts`). Added `src/scripts/{_db-cli,db-migrate,db-status,db-stamp}.ts` mirroring nanodrop/inventory precedent. 24 existing `initDb` callsites in tests preserved unchanged (8 test files: server-upload, authd-handshake, sessions, session-renewal, worker, worker-lifecycle, queue, server-routes, session-persistence). Refactor commit fb53ab8 cosmetic only — consolidated two `node:path` imports (`import path from 'node:path'; import { dirname } from 'node:path'`) into one (`import { dirname, resolve } from 'node:path'`); behavior delta zero. 83/83 vitest specs green (was 82; +1 new byte-stable). tsc --noEmit + npm run build clean. ENV_VAR_GATE clean (`DB_MIGRATIONS_STAMP_GENESIS` only new read, optional default-off via `=== '1'` strict equality, matches authd/buchinese/nanodrop/inventory precedent — not env-blocked). SECURITY None — pure dep swap, no new HTTP/auth/secrets surface, applyMigrations execs static repo-content SQL via FILENAME_RE path-traversal defense in package, new db:* CLI scripts operator-only (grep confirms no route handler imports them). Lockfile-verified: `rm -rf node_modules package-lock.json && npm install --include=optional` to regenerate then `rm -rf node_modules && npm ci` cold-install exit 0 (sandbox-side); `docker build .` deferred to first prod deploy (Docker daemon socket not reachable from sandbox — same caveat as authd PR #15 / buchinese PR #14 / nanodrop PR #9 / inventory PR #21). Dockerfile already has `git` (PR #4 landed earlier 2026-05-12) so the `git+https://` URL resolves at install time. Auto-merge via Gitea API POST /pulls (PR #5 opened mergeable=true head.sha=fb53ab8 bit-for-bit match REVIEWED_SHA, no SHA drift) + POST /merge with `Do=merge head_commit_id=fb53ab8…`; merge_commit `4a9b2f68…`; feature branch `feat/adopt-sqlite-migrate` deleted post-merge; worktree cleaned via `git worktree remove --force`. First prod boot will log `WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'sessions' table)` then `migrations: 1 applied, 0 pending`; subsequent boots `0 applied, 0 pending`. Phase 3 remaining: movement only. --> <!-- Phase 3 inventory RESOLVED 2026-05-12 — third non-authd consumer adopted. PR https://gitea.bchen.dev/brendan/inventory/pulls/21 (merge_commit 31ee968533b1a6650d3c1fa61cdf4911442b67b7). REVIEWED_SHA=4f2ef3c5d1fe9fecae7f74b412417fa25fa0d89e (single feat commit; refactor pass noop). inventory now imports `applyMigrations` from `bchen-sqlite-migrate@v0.1.0` (git+https URL pinned to v0.1.0 tag = commit `7dbce6637c…`). `src/db/schema.ts` rewritten as thin wrapper (`applySchema(db)` passes `genesisProbeTable: 'sessions'` no logger so existing test callers stay silent; `initDb(dbPath)` reads `DB_MIGRATIONS_STAMP_GENESIS` env and applies migrations with verbose stdout). Inline 60-line SCHEMA_DDL template literal removed; new `src/db/migrations/0001_init.sql` captures the prior DDL byte-for-byte (5 tables — sessions, oauth_pending, items, attachments, item_history — plus all associated indexes; sha256 `e84d544beaea2ed7538ecaafec29cc898975f209870b9d49f5d8974a8b17c808` pinned by `tests/unit/migrations-byte-stable.test.ts`). Added `src/scripts/{_db-cli,db-migrate,db-status,db-stamp}.ts` mirroring authd/buchinese/nanodrop precedent (5 existing `applySchema` callsites in tests preserved unchanged: `tests/helpers/setup.ts`, `tests/unit/sessions.test.ts`, `tests/unit/oauth-pending.test.ts`, `tests/integration/migrate-users-cli.test.ts`, plus `src/index.ts:12` via `initDb`). 203/203 vitest specs green (was 202; +1 new byte-stable). tsc --noEmit + npm run build clean. ENV_VAR_GATE clean (`DB_MIGRATIONS_STAMP_GENESIS` only new read, optional default-off, matches authd/buchinese/nanodrop precedent — not env-blocked). SECURITY None — pure dep swap, no new HTTP/auth/secrets surface, applyMigrations execs static repo-content SQL via FILENAME_RE path-traversal defense in package. Lockfile-verified: `rm -rf node_modules package-lock.json && npm install --include=optional` to regenerate then `rm -rf node_modules && npm ci` cold-install exit 0 (315 packages, 0 vulnerabilities, sandbox-side); `docker build .` deferred to first prod deploy (Docker daemon socket not reachable from sandbox — same caveat as authd PR #15 / buchinese PR #14 / nanodrop PR #9). Auto-merge via Gitea API POST /pulls (PR #21 opened mergeable=true head.sha=4f2ef3c bit-for-bit match REVIEWED_SHA, no SHA drift) + POST /merge with `Do=merge head_commit_id=4f2ef3c5…`; merge_commit `31ee9685…`; feature branch `feat/adopt-sqlite-migrate` deleted post-merge; worktree cleaned via `git worktree remove --force`. First prod boot will log `WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'sessions' table)` then `migrations: 1 applied, 0 pending`; subsequent boots `0 applied, 0 pending`. Phase 3 remaining: dashcam, movement — one PR each. --> <!-- Phase 3 movement RESOLVED 2026-05-13 — fifth and final non-authd consumer adopted (Phase 3 5-of-5). PR https://gitea.bchen.dev/brendan/movement/pulls/18 (merge_commit bc7eedd66cf45018cd442eac60ff2055268b6860). REVIEWED_SHA=8340ee79e9ffbcf98403a47a2e38a2137e62ffa6 (single feat commit; refactor pass noop). movement now imports `applyMigrations` from `bchen-sqlite-migrate@v0.1.0` (git+https URL pinned to v0.1.0 tag = commit `7dbce6637c…`). `src/server/db/migrations.ts` rewritten to call `applyMigrations(db, MIGRATIONS_DIR, { stampGenesis, genesisProbeTable: 'users', logger })`; `runMigrations(db)` signature preserved so the 3 existing callers (server/index.ts, cli/register-user.ts, tests/helpers/db.helper.ts) stay untouched. Old `src/server/db/schema.sql` renamed to `src/server/db/migrations/0001_init.sql` (git rename — file body byte-identical); sha256 `8ae324fce12293bcee1e7d251d50f78288ea38bacaf2cb66fb47c91f52a9d52a` pinned by `tests/unit/migrations-byte-stable.test.ts`. Added `src/server/cli/{_db-cli,db-migrate,db-status,db-stamp}.ts` mirroring authd/buchinese/nanodrop/inventory/dashcam precedent + `db:migrate` / `db:status` / `db:stamp` npm scripts. ENV_VAR_GATE clean (`DB_MIGRATIONS_STAMP_GENESIS` only new read, optional default-off via `=== '1'` strict equality, matches all prior precedent — not env-blocked). SECURITY None — pure dep swap, no new HTTP/auth/secrets surface, applyMigrations execs static repo-content SQL via FILENAME_RE path-traversal defense in package, new db:* CLI scripts operator-only. Native-dep compatibility confirmed by the prior Dockerfile chore (PR #17 bumped movement to node:24-alpine + git; better-sqlite3 + bcrypt node-24 prebuilds verified). First prod boot will log `WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'users' table)` then `migrations: 1 applied, 0 pending`; subsequent boots `0 applied, 0 pending`. Phase 3 5-of-5 complete; the whole sqlite-migrate cross-project extraction is now done end-to-end. --> <!-- FULLY RESOLVED 2026-05-13 — Phase 1 (package shipped v0.1.0), Phase 2 (authd dogfooded), Phase 3 (all 5 non-authd consumers adopted: buchinese PR #14, nanodrop PR #9, inventory PR #21, dashcam PR #5, movement PR #18) all merged. The shared `bchen-sqlite-migrate@v0.1.0` runner is now the single source of truth across the fleet; six divergent `applySchema(db)` blobs are gone. Future schema changes ship as numbered `.sql` files; the buchinese article-scoping feature filed below can now ship its delta as `0002_articles_authd_sub.sql` against the just-landed runner. --> **Motivation.** authd PR #14 (merge_commit `ced21ab4d16c28a6b5fafa9072b0ae8207ba4bfd`, merged 2026-05-11) landed a real migration system: numbered `.sql` files in `src/db/migrations/`, a `schema_migrations` tracking table, sha256 checksum verification, idempotent re-apply, genesis-stamping for pre-existing prod DBs, and three CLI scripts (`db:migrate` / `db:status` / `db:stamp`). The runner lives in `src/db/migrate.ts` (178 LOC, exports `applyMigrations`, `listMigrations`, `readAppliedRows`, `stampMigration`) and is exercised by `tests/db/migrate.test.ts` (270 LOC, 9 specs covering fresh-apply / idempotent / checksum-mismatch / genesis-stamp / partway-failure rollback). Meanwhile, every other SQLite-using project still ships a single `applySchema(db)` function that runs `CREATE TABLE IF NOT EXISTS` for every table on every boot and does ad-hoc ALTER patches when schema needs to evolve (e.g. `src/db/schema.ts:111-122` in buchinese for the `sessions.expires_at` retrofit). This is fine for a fresh DB but rots fast: there's no way to add a new column to an existing table without writing the same idempotent-probe pattern by hand each time, and there's no record of which schema state any given prod DB is at. The next migration in any of these projects (e.g. the buchinese article user-scoping feature filed below) would re-invent the same probe-and-ALTER pattern again. One package, six consumers — much better than six divergent copies. **Inventory of consumers.** | project | schema entry point | tables (approx) | first table for genesis probe | |---|---|---|---| | authd | `src/db/schema.ts` `applySchema` → already replaced by `applyMigrations` in PR #14, but currently *inlines* the runner under `src/db/migrate.ts`. After this feature, authd imports the runner from the package and deletes its inline copy. | users, login_attempts, oauth_*, mfa_* | `users` | | buchinese | `src/db/schema.ts:109-123` `applySchema` | sessions, oauth_pending, articles, article_tags, article_tokens, article_sentences, flashcards, flashcard_review_history | `sessions` | | inventory | `src/db/schema.ts` `applySchema` | (audit during implementation) | TBD | | nanodrop | `src/db/schema.ts:3` `initDb` | users, files, login_attempts | `users` | | dashcam | `src/db/schema.ts:6` `initDb` | sessions, oauth_pending, uploads, processing_queue, clips | `sessions` | | movement | `src/server/db/client.ts` (no central `applySchema`; DDL is split across `users.ts`, `login-attempts.ts`, etc.) | users, login_attempts, … | `users` | Each project's genesis migration (`0001_init.sql`) just captures whatever the current `applySchema` block emits, byte-for-byte; the runner then stamps it as applied on the first migrate-run against an existing prod DB and applies zero rows. **Package shape.** - **Repo:** `gitea.bchen.dev/brendan/sqlite-migrate` (must be **public** so `npm install` from a non-authenticated context — e.g. a `Dockerfile RUN npm ci` inside a public CI runner — can fetch the tarball without injecting credentials). The repo is created fresh, not forked; it doesn't carry any of authd's history. - **Package name:** `@bchen/sqlite-migrate` (scoped name) or unscoped `bchen-sqlite-migrate` — implementer picks based on whichever installs cleanly across consumers; scoped names with custom registries get fussy when there's no `.npmrc`, so unscoped is the safer default for direct-URL installs. Document the chosen name in the package README. - **License:** MIT, to keep the public-repo decision frictionless. - **Install form** (consumers' `package.json` dependency): ```json "bchen-sqlite-migrate": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0" ``` Use HTTPS + a version tag (not SSH, because Docker builds inside CI don't have SSH keys). Public repo means no token needed in the URL. **Pin to a tag** (`#v0.1.0`), not a branch, so consumers don't silently pick up breaking changes on the next `npm install`. Tags follow semver. - **Exports** (`package.json` `"exports"` map; ESM-only, matching the existing TS code): - `"."` → the runner API: `applyMigrations`, `listMigrations`, `readAppliedRows`, `stampMigration`, plus the public types (`MigrationFile`, `MigrationRow`, `MigrationSummary`, `ApplyOptions`). - `"./cli"` → optional helper that consumers can wire from their own `tsx src/scripts/db-migrate.ts` shim — a single `runMigrateCli({ openDb, migrationsDir })` function so each consumer just writes a 5-line script that injects its config-loading function. The package does NOT include a default `bin/` entry because the DB path / config loader differs per project (authd's `loadConfig()` ≠ buchinese's, etc.). - **Build:** ship pre-compiled `.js` + `.d.ts` so consumers don't need to wire `tsx` for the package itself; their consumer code can still be `.ts`. Use the existing `tsc --emitDeclarationOnly` + a tiny `esbuild` step to bundle to `dist/`. The `"files"` field in `package.json` limits the published surface to `dist/`, `README.md`, `LICENSE`. **Parameterization.** The runner today hard-codes one thing that has to become configurable for cross-project use: - `GENESIS_PROBE_TABLE = 'users'` (`src/db/migrate.ts:34` in authd). Replace with an `ApplyOptions.genesisProbeTable?: string` parameter (default `'users'`, to preserve authd's behavior when authd consumes the package). Each consumer passes its own first-table name from the table above. Without this, the runner would refuse to stamp a buchinese DB (no `users` table) and would try to *re-run* `0001_init.sql` on top of an existing populated DB → instant failure on `CREATE TABLE` for tables that already exist. The rest of the runner stays as-is — version regex, checksum behavior, transaction-per-migration, the rollback-on-failure semantics, and the WARN log line on genesis-stamp all remain identical. **Scope.** 1. **New repo + package** - Create `gitea.bchen.dev/brendan/sqlite-migrate` (public). Use `tea repo create --name sqlite-migrate --owner brendan --init`. - Lift `src/db/migrate.ts` + `tests/db/migrate.test.ts` from authd; add `genesisProbeTable` option; preserve the 9 existing test specs and add three new ones: - probe-table-not-present + stampGenesis=false → first migration runs normally (no stamp). - genesisProbeTable=undefined defaults to `'users'` (authd back-compat). - genesisProbeTable=`'sessions'` correctly stamps a buchinese-shaped DB. - Wire `runMigrateCli({ openDb, migrationsDir, stampGenesis })` thin wrapper extracted from authd's `src/scripts/db-migrate.ts` (so consumers don't re-copy the 25-line CLI shim). - Build pipeline: `npm run build` → `dist/` with `.js` + `.d.ts`; `npm test` runs vitest. - `package.json` with the chosen name, `"type": "module"`, `"main": "./dist/index.js"`, `"types": "./dist/index.d.ts"`, peer-dep on `better-sqlite3` (do NOT bundle it; consumers already have it pinned). - Tag `v0.1.0` after CI is green. 2. **Adopt in authd (dogfood — first consumer, lowest risk because it already has the runner inlined)** - `npm i bchen-sqlite-migrate@git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0` - Delete `src/db/migrate.ts` (now in the package). - Update `src/scripts/_db-cli.ts` + the three `db-*.ts` scripts to import from the package. - Update `tests/db/migrate.test.ts` to import from the package OR delete it (package owns those tests now). Recommended: keep a thin authd-specific test that asserts `0001_init.sql` is byte-stable, since checksum drift would break prod migrations. - Verify `npm run db:migrate` against a fresh tempdir and against a pre-existing dev DB → both behave identically to pre-extraction. 3. **Adopt in buchinese, inventory, nanodrop, dashcam, movement (one PR per project)** - Add the package dependency. - Create `src/db/migrations/0001_init.sql` containing exactly the current `applySchema` body (or, for movement, the union of the split DDLs). - For buchinese: also bake in the `sessions.expires_at` ALTER + backfill (lines 111-122 of `src/db/schema.ts`) into `0001_init.sql` — by the time this migration system lands, prod has already been through that one-shot, so the genesis stamp will skip executing `0001_init.sql` on existing DBs anyway. - Replace `applySchema(db)` (or the per-project equivalent) with a call to `applyMigrations(db, migrationsDir, { stampGenesis: process.env.DB_MIGRATIONS_STAMP_GENESIS === '1', genesisProbeTable: '<project first table>' })`. - Add `db:migrate`, `db:status`, `db:stamp` npm scripts (copy from authd's package.json). - Per-project sanity test: spin up an empty tempdir DB → `applyMigrations` → assert every expected table exists. And: pre-seed a DB with the current tables + no `schema_migrations` → `applyMigrations` → assert it stamps `0001` and applies nothing. 4. **Per-deploy bootstrap doc** (one-line in each project's README): "first prod boot after this PR should show `WARN genesis-stamp: marked 0001_init as applied without executing` followed by `migrations: 1 applied, 0 pending` in the container log. If you see `CREATE TABLE … already exists` instead, the genesis probe table is wrong — file a bug." 5. **Cross-cut with the buchinese article-scoping feature filed below.** Once this package is adopted in buchinese, the article user-scoping feature filed in this same `features.md` block can ship its schema delta as `0002_articles_authd_sub.sql` instead of inventing a one-off ad-hoc ALTER pattern. The implementer should pick up this package adoption *before* the article-scoping migration so the new convention is in place. **Out of scope.** - Publishing to the public npm registry. The user explicitly asked for direct Gitea URL installs; npm publish is a separate decision and adds release-key management. - Down-migrations / rollbacks. The authd implementation is forward-only on purpose (`MigrationSummary` has no `rolled_back` field). Adding rollback support adds operational complexity (which down do you run, how do you handle data loss) and isn't needed for any current project. - Multi-statement transactional safety beyond what better-sqlite3's `db.exec()` already provides. The runner wraps each migration in `db.transaction(...)` — if a migration mixes DDL and DML and the DML fails, the whole migration rolls back. That's enough. - Schema diffing or auto-generation. Each migration is hand-authored SQL. No ORM, no diff tool. - Postgres / MySQL support. SQLite-only, matching every consumer's current reality. If a future project lands on Postgres it'll use its own ecosystem (`node-pg-migrate`, `drizzle-kit`, etc.) — no reason to retrofit one runner across dialects. - A web UI for migration status. `db:status` is a CLI that prints a table. **Acceptance.** - `gitea.bchen.dev/brendan/sqlite-migrate` is public, tagged `v0.1.0`, with a green CI run on the tag. - `npm i git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0` works from a fresh shell with no Gitea credentials (this is the public-repo proof). - authd's main branch consumes the package; its inline `src/db/migrate.ts` is deleted; all existing authd tests still pass; `npm run db:migrate` against a pre-existing prod-shaped DB produces the same WARN-stamp output as before extraction. - All five other SQLite projects have adopted the package, each with their own `0001_init.sql`. For each project, a fresh DB and a pre-existing prod-shaped DB both reach the same final schema state under `npm run db:migrate`. - Document the install command + the `DB_MIGRATIONS_STAMP_GENESIS` env var in the package README, with a section per consumer project showing the exact `applyMigrations` call signature (including the right `genesisProbeTable`).
brendan added the feature label 2026-05-13 19:02:01 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: brendan/nanodrop#16