# bchen-sqlite-migrate Tiny, forward-only SQLite migration runner for `better-sqlite3`: numbered `.sql` files, sha256 checksum verification, idempotent re-apply, transactional per-migration, and genesis-stamping for pre-existing prod DBs. Lifted from the [authd](https://gitea.bchen.dev/brendan/authd) auth service (PR #14, merge `ced21ab`) and generalized for cross-project use across the fleet. ## Install Public repo — no auth needed: ```bash npm install git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0 ``` Pin to a tag (`#v0.1.0`), not a branch. Peer dependency: ```json "peerDependencies": { "better-sqlite3": ">=11 <13" } ``` Node 20+. ESM-only. Ships pre-compiled `dist/`, so no build step is needed in consumers. ## Quick start ```ts import Database from 'better-sqlite3'; import { applyMigrations } from 'bchen-sqlite-migrate'; const db = new Database('./app.db'); const summary = applyMigrations(db, './migrations', { genesisProbeTable: 'users', logger: console.log, }); // summary: { applied, alreadyApplied, pending, stamped } ``` Migration files live in a single flat directory and follow the naming convention `NNNN_name.sql` (4+ digits, snake_case name, `.sql` extension), e.g. `0001_init.sql`, `0002_articles_user_scope.sql`. ## API ### `applyMigrations(db, migrationsDir, opts?) => MigrationSummary` Apply all pending migrations in version order. Each migration runs inside a transaction; failure rolls back. Re-running is idempotent — already-applied migrations are skipped after a checksum compare; mismatch throws. ```ts interface ApplyOptions { /** Per-message logger (info + WARN). Default no-op. */ logger?: (msg: string) => void; /** Force-stamp the 0001 migration without executing it. */ stampGenesis?: boolean; /** Default 'users'. Probe table for "is this a pre-existing prod DB?". */ genesisProbeTable?: string; } interface MigrationSummary { applied: number; // ran successfully this call pending: number; // always 0; kept for forward-compat alreadyApplied: number; // already in schema_migrations stamped: string[]; // versions stamped without execution } ``` ### `listMigrations(migrationsDir) => MigrationFile[]` Reads + parses `NNNN_*.sql` files into `{ version, name, checksum, body }`. Throws on filenames that don't match `^(\d{4,})_([a-z0-9_]+)\.sql$`. ### `readAppliedRows(db) => MigrationRow[]` Reads `schema_migrations`. Returns `[]` if the table doesn't exist. ### `stampMigration(db, migrationsDir, version) => MigrationFile` Marks `version` as applied without running its body. Throws if already stamped. Useful for retrofitting an existing-prod DB to a new migration scheme. ## Genesis stamping When applying against a DB that has tables but no `schema_migrations` row, the runner can mark the `0001` migration as "applied" without executing its body. This lets you adopt the migration system on an existing prod DB without re-running `CREATE TABLE` against tables that already exist. Two triggers: 1. `applyMigrations(db, dir, { stampGenesis: true })` — explicit opt-in. Typically wired to an env var: ```bash DB_MIGRATIONS_STAMP_GENESIS=1 node ./scripts/db-migrate.mjs ``` 2. `genesisProbeTable` — auto-detection. If this table exists in the DB and `schema_migrations` doesn't, stamp 0001. Default `'users'`. Pass your project's first-table name to match a non-authd shape. When stamping fires, the runner emits one WARN line via `logger`: ``` WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'users' table) ``` Or, when triggered explicitly: ``` WARN genesis-stamp: marked 0001_init as applied without executing (stampGenesis option set) ``` ## Per-consumer call signatures (fleet) | Consumer | `genesisProbeTable` | |-------------|---------------------| | authd | `'users'` (default — can omit) | | buchinese | `'sessions'` | | inventory | TBD — verify at adoption | | nanodrop | `'users'` | | dashcam | `'sessions'` | | movement | TBD — verify at adoption | ## CLI helper The package exports a wrapper at the `bchen-sqlite-migrate/cli` sub-path so each consumer writes a ~5-line shim: ```ts // scripts/db-migrate.ts import { runMigrateCli } from 'bchen-sqlite-migrate/cli'; import { openDb } from './_db-open.js'; process.exit(runMigrateCli({ openDb, migrationsDir: new URL('../db/migrations/', import.meta.url).pathname, command: process.argv[2] as 'migrate' | 'status' | 'stamp', version: process.argv[3], stampGenesis: process.env.DB_MIGRATIONS_STAMP_GENESIS === '1', genesisProbeTable: 'users', })); ``` `runMigrateCli` **returns** an exit code rather than calling `process.exit` itself — the consumer is responsible for `process.exit(...)`. The wrapper closes the DB before returning. This keeps the helper testable (you can capture stdout/stderr via `PassThrough` streams). Three commands: - `migrate` — apply pending migrations. - `status` — print each migration's state (`applied:` / `pending:` / `checksum-mismatch:`). Exit code `2` if any mismatched. - `stamp ` — manually mark a version as applied without running its body. ## Development ```bash git clone ssh://git@gitea.bchen.dev:2222/brendan/sqlite-migrate.git cd sqlite-migrate npm install npm test npm run build ``` `dist/` is committed (consumers install via `git+https://...#v0.1.0` and need the pre-built JS). CI verifies `dist/` is in sync with `src/` via `git diff --exit-code dist/`. Re-run `npm run build` before committing if you touched `src/`. ## License MIT — see [LICENSE](./LICENSE). ## Security This is a server-side library with no HTTP surface and no untrusted input. `db.exec(file.body)` runs each migration's SQL verbatim — **by design**, since migrations are trusted, committed-to-repo SQL. Do not point `migrationsDir` at a path that can be written to by untrusted users.