feat(migrate): shared migrator package + consolidate server schema into migrations#26
Conversation
…to migrations Introduces @relayauth/migrate, an adapter-agnostic migration orchestrator backed by a _migrations journal table with sha256 checksum drift detection. Ships a Node SQLite runner and fs-based migration source today; the MigrationRunner interface is async/generic so a follow-up PR can plug in a Cloudflare D1 runner without touching this core. @relayauth/server now applies its schema via runMigrations at adapter boot. The 13 inline CREATE TABLE blocks that used to live in storage/sqlite.ts are consolidated into packages/server/src/db/migrations/0001_local_bootstrap.sql, which is the new source of truth. ensureTokensSchema / ensureRevokedTokensSchema remain as runtime column-shape guards, but no longer own any DDL. Follow-up: cloud's D1 adapter will build on top of this package. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| * rolls back the partial DDL. The `_migrations` insert also participates in | ||
| * the transaction, so crashes between exec and recordApplied are impossible. |
There was a problem hiding this comment.
🟡 recordApplied runs outside the transaction committed by exec, contradicting documented atomicity guarantee
The JSDoc for createNodeSqliteRunner claims "The _migrations insert also participates in the transaction, so crashes between exec and recordApplied are impossible." This is false. The exec method wraps the migration SQL in BEGIN/COMMIT (lines 43-46), and then runMigrations calls recordApplied as a separate step (packages/migrate/src/runner.ts:72) after exec has already committed. The recordApplied method (packages/migrate/src/runners/node-sqlite.ts:73-76) runs a standalone INSERT OR IGNORE with no transaction.
A process crash in the window between exec committing and recordApplied inserting the journal row would leave the migration applied to the database but not recorded in _migrations. On restart, runMigrations would re-execute that migration's SQL. For the current idempotent CREATE TABLE IF NOT EXISTS usage this is benign, but for non-idempotent migrations (which are the common case for a general-purpose framework like @relayauth/migrate), re-execution could fail or cause data corruption. Notably, the MigrationRunner interface doc in packages/migrate/src/types.ts:59-67 correctly acknowledges this crash window exists, directly contradicting this JSDoc.
| * rolls back the partial DDL. The `_migrations` insert also participates in | |
| * the transaction, so crashes between exec and recordApplied are impossible. | |
| * Each migration runs inside a `BEGIN`/`COMMIT` transaction so a failure | |
| * rolls back the partial DDL. The journal insert (`recordApplied`) runs | |
| * after the transaction commits; `INSERT OR IGNORE` keeps this idempotent | |
| * so a crash between exec and recordApplied is recoverable by re-running. |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
@relayauth/migrate— an adapter-agnostic migration runner with a_migrationsjournal and sha256 checksum drift detection. Ships acreateNodeSqliteRunner(node:sqlite / better-sqlite3) andcreateFsMigrationSourcetoday. TheMigrationRunnerinterface is fully async so the cloud D1 adapter can plug in a custom runner in a follow-up PR.@relayauth/servernow applies its SQLite schema viarunMigrationsat adapter boot. The 13 inlineCREATE TABLE IF NOT EXISTSblocks that used to live inpackages/server/src/storage/sqlite.tsare consolidated intopackages/server/src/db/migrations/0001_local_bootstrap.sql, which becomes the source of truth.ensureTokensSchema/ensureRevokedTokensSchemahelpers remain as runtime column-shape guards (ALTER-only fallbacks and a clear error on an ancient column shape) but no longer own any DDL.Rationale
Having schema live inline in an adapter meant it drifted silently vs the
0001_local_bootstrap.sqlfile it was supposed to mirror, and there was no journal for evolving beyondCREATE TABLE IF NOT EXISTS. Centralising the migrator into a standalone package lets cloud's D1 adapter reuse the same journal, checksum, and runMigrations contract without importing anything Node-specific.Follow-up
Cloud-side (D1) adoption — a new runner implementing
MigrationRunnerbacked by the async D1 binding — will ship in a separate PR after this one merges. Nothing in the cloud repo changes in this PR.Test plan
node --test --import tsx packages/migrate/src/__tests__/*.test.ts— 8 tests pass (fresh DB, rerun, new migration added, checksum mismatch, empty directory, nonexistent directory, malformed SQL rollback, non-sql files ignored).node --test --import tsx packages/server/src/__tests__/*.test.ts— 324 tests pass.npx turbo typecheck— 11 tasks succeed.npx turbo build— 9 tasks succeed;dist/db/migrations/0001_local_bootstrap.sqlis copied into the built server bundle.grep "CREATE TABLE" packages/server/src/storage/sqlite.ts— no inline DDL remaining.