extract authd's SQLite migration system into a standalone npm package #16
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.sqlfiles insrc/db/migrations/, aschema_migrationstracking 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 insrc/db/migrate.ts(178 LOC, exportsapplyMigrations,listMigrations,readAppliedRows,stampMigration) and is exercised bytests/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 runsCREATE TABLE IF NOT EXISTSfor every table on every boot and does ad-hoc ALTER patches when schema needs to evolve (e.g.src/db/schema.ts:111-122in buchinese for thesessions.expires_atretrofit). 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.
src/db/schema.tsapplySchema→ already replaced byapplyMigrationsin PR #14, but currently inlines the runner undersrc/db/migrate.ts. After this feature, authd imports the runner from the package and deletes its inline copy.userssrc/db/schema.ts:109-123applySchemasessionssrc/db/schema.tsapplySchemasrc/db/schema.ts:3initDbuserssrc/db/schema.ts:6initDbsessionssrc/server/db/client.ts(no centralapplySchema; DDL is split acrossusers.ts,login-attempts.ts, etc.)usersEach project's genesis migration (
0001_init.sql) just captures whatever the currentapplySchemablock 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.
gitea.bchen.dev/brendan/sqlite-migrate(must be public sonpm installfrom a non-authenticated context — e.g. aDockerfile RUN npm ciinside 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.@bchen/sqlite-migrate(scoped name) or unscopedbchen-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.package.jsondependency):#v0.1.0), not a branch, so consumers don't silently pick up breaking changes on the nextnpm install. Tags follow semver.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 owntsx src/scripts/db-migrate.tsshim — a singlerunMigrateCli({ openDb, migrationsDir })function so each consumer just writes a 5-line script that injects its config-loading function. The package does NOT include a defaultbin/entry because the DB path / config loader differs per project (authd'sloadConfig()≠ buchinese's, etc.)..js+.d.tsso consumers don't need to wiretsxfor the package itself; their consumer code can still be.ts. Use the existingtsc --emitDeclarationOnly+ a tinyesbuildstep to bundle todist/. The"files"field inpackage.jsonlimits the published surface todist/,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:34in authd). Replace with anApplyOptions.genesisProbeTable?: stringparameter (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 (nouserstable) and would try to re-run0001_init.sqlon top of an existing populated DB → instant failure onCREATE TABLEfor 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.
New repo + package
gitea.bchen.dev/brendan/sqlite-migrate(public). Usetea repo create --name sqlite-migrate --owner brendan --init.src/db/migrate.ts+tests/db/migrate.test.tsfrom authd; addgenesisProbeTableoption; preserve the 9 existing test specs and add three new ones:'users'(authd back-compat).'sessions'correctly stamps a buchinese-shaped DB.runMigrateCli({ openDb, migrationsDir, stampGenesis })thin wrapper extracted from authd'ssrc/scripts/db-migrate.ts(so consumers don't re-copy the 25-line CLI shim).npm run build→dist/with.js+.d.ts;npm testruns vitest.package.jsonwith the chosen name,"type": "module","main": "./dist/index.js","types": "./dist/index.d.ts", peer-dep onbetter-sqlite3(do NOT bundle it; consumers already have it pinned).v0.1.0after CI is green.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.0src/db/migrate.ts(now in the package).src/scripts/_db-cli.ts+ the threedb-*.tsscripts to import from the package.tests/db/migrate.test.tsto import from the package OR delete it (package owns those tests now). Recommended: keep a thin authd-specific test that asserts0001_init.sqlis byte-stable, since checksum drift would break prod migrations.npm run db:migrateagainst a fresh tempdir and against a pre-existing dev DB → both behave identically to pre-extraction.Adopt in buchinese, inventory, nanodrop, dashcam, movement (one PR per project)
src/db/migrations/0001_init.sqlcontaining exactly the currentapplySchemabody (or, for movement, the union of the split DDLs).sessions.expires_atALTER + backfill (lines 111-122 ofsrc/db/schema.ts) into0001_init.sql— by the time this migration system lands, prod has already been through that one-shot, so the genesis stamp will skip executing0001_init.sqlon existing DBs anyway.applySchema(db)(or the per-project equivalent) with a call toapplyMigrations(db, migrationsDir, { stampGenesis: process.env.DB_MIGRATIONS_STAMP_GENESIS === '1', genesisProbeTable: '<project first table>' }).db:migrate,db:status,db:stampnpm scripts (copy from authd's package.json).applyMigrations→ assert every expected table exists. And: pre-seed a DB with the current tables + noschema_migrations→applyMigrations→ assert it stamps0001and applies nothing.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 executingfollowed bymigrations: 1 applied, 0 pendingin the container log. If you seeCREATE TABLE … already existsinstead, the genesis probe table is wrong — file a bug."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.mdblock can ship its schema delta as0002_articles_authd_sub.sqlinstead 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.
MigrationSummaryhas norolled_backfield). 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.db.exec()already provides. The runner wraps each migration indb.transaction(...)— if a migration mixes DDL and DML and the DML fails, the whole migration rolls back. That's enough.node-pg-migrate,drizzle-kit, etc.) — no reason to retrofit one runner across dialects.db:statusis a CLI that prints a table.Acceptance.
gitea.bchen.dev/brendan/sqlite-migrateis public, taggedv0.1.0, with a green CI run on the tag.npm i git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0works from a fresh shell with no Gitea credentials (this is the public-repo proof).src/db/migrate.tsis deleted; all existing authd tests still pass;npm run db:migrateagainst a pre-existing prod-shaped DB produces the same WARN-stamp output as before extraction.0001_init.sql. For each project, a fresh DB and a pre-existing prod-shaped DB both reach the same final schema state undernpm run db:migrate.DB_MIGRATIONS_STAMP_GENESISenv var in the package README, with a section per consumer project showing the exactapplyMigrationscall signature (including the rightgenesisProbeTable).