Merge pull request 'feat: v0.1.0 — sqlite-migrate package extraction from authd' (#1) from feat/initial-package into main
All checks were successful
ci / test (push) Successful in 11s

This commit was merged in pull request #1.
This commit is contained in:
2026-05-12 09:30:50 +00:00
19 changed files with 3098 additions and 2 deletions

21
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,21 @@
name: ci
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- name: ensure dist is up to date
run: git diff --exit-code dist/

3
.gitignore vendored
View File

@@ -136,3 +136,6 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# package: ship pre-compiled dist/
!/dist/

162
README.md
View File

@@ -1,3 +1,161 @@
# sqlite-migrate # bchen-sqlite-migrate
Tiny SQLite migration runner: version-numbered, checksum-verified, idempotent. Used across multiple projects. 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:<v>` / `pending:<v>` / `checksum-mismatch:<v>`). Exit code `2` if any mismatched.
- `stamp <version>` — 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.

18
dist/cli.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import type Database from 'better-sqlite3';
export type DbCommand = 'migrate' | 'status' | 'stamp';
export interface RunCliOptions {
/** Caller-controlled DB open. Wrapper closes it before returning. */
openDb: () => Database.Database;
migrationsDir: string;
command: DbCommand;
/** Required for `stamp`. */
version?: string;
/** Default false. Forwarded to applyMigrations. */
stampGenesis?: boolean;
/** Default 'users'. Forwarded to applyMigrations. */
genesisProbeTable?: string;
/** Default process.stdout / process.stderr. Wired for tests. */
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
}
export declare function runMigrateCli(opts: RunCliOptions): number;

71
dist/cli.js vendored Normal file
View File

@@ -0,0 +1,71 @@
import { applyMigrations, listMigrations, readAppliedRows, stampMigration, } from './migrate.js';
export function runMigrateCli(opts) {
const stdout = opts.stdout ?? process.stdout;
const stderr = opts.stderr ?? process.stderr;
if (opts.command === 'stamp') {
if (!opts.version) {
stderr.write('error: version required\n');
return 1;
}
const db = opts.openDb();
try {
const file = stampMigration(db, opts.migrationsDir, opts.version);
stdout.write(`stamped:${file.version}\n`);
db.close();
return 0;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
stderr.write(`error: ${msg}\n`);
db.close();
return 1;
}
}
if (opts.command === 'status') {
const db = opts.openDb();
const files = listMigrations(opts.migrationsDir);
const appliedByVersion = new Map(readAppliedRows(db).map((r) => [r.version, r]));
let exitCode = 0;
const pending = [];
const mismatched = [];
for (const file of files) {
const applied = appliedByVersion.get(file.version);
if (!applied) {
pending.push(file.version);
stdout.write(`pending:${file.version}\n`);
continue;
}
if (applied.checksum !== file.checksum) {
mismatched.push(file.version);
stdout.write(`checksum-mismatch:${file.version}\n`);
exitCode = 2;
continue;
}
stdout.write(`applied:${file.version}\n`);
}
if (pending.length === 0 && mismatched.length === 0) {
stdout.write(`pending:none\n`);
}
db.close();
return exitCode;
}
// migrate
const db = opts.openDb();
const summary = applyMigrations(db, opts.migrationsDir, {
stampGenesis: opts.stampGenesis,
genesisProbeTable: opts.genesisProbeTable,
logger: (msg) => stdout.write(`${msg}\n`),
});
if (summary.stamped.length > 0) {
stdout.write(`stamped:${summary.stamped.join(',')}\n`);
}
if (summary.applied === 0 && summary.stamped.length === 0) {
stdout.write(`pending:none\n`);
}
else if (summary.applied > 0) {
stdout.write(`applied:${summary.applied}\n`);
}
stdout.write(`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`);
db.close();
return 0;
}

2
dist/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export { applyMigrations, listMigrations, readAppliedRows, stampMigration, } from './migrate.js';
export type { ApplyOptions, MigrationFile, MigrationRow, MigrationSummary, } from './migrate.js';

1
dist/index.js vendored Normal file
View File

@@ -0,0 +1 @@
export { applyMigrations, listMigrations, readAppliedRows, stampMigration, } from './migrate.js';

29
dist/migrate.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
import type Database from 'better-sqlite3';
export interface MigrationFile {
version: string;
name: string;
checksum: string;
body: string;
}
export interface MigrationRow {
version: string;
name: string;
checksum: string;
applied_at: number;
}
export interface MigrationSummary {
applied: number;
pending: number;
alreadyApplied: number;
stamped: string[];
}
export interface ApplyOptions {
logger?: (msg: string) => void;
stampGenesis?: boolean;
/** Default 'users'. Probe table that decides "is this a pre-existing prod DB needing genesis-stamping?". */
genesisProbeTable?: string;
}
export declare function listMigrations(migrationsDir: string): MigrationFile[];
export declare function readAppliedRows(db: Database.Database): MigrationRow[];
export declare function applyMigrations(db: Database.Database, migrationsDir: string, opts?: ApplyOptions): MigrationSummary;
export declare function stampMigration(db: Database.Database, migrationsDir: string, version: string): MigrationFile;

110
dist/migrate.js vendored Normal file
View File

@@ -0,0 +1,110 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
const FILENAME_RE = /^(\d{4,})_([a-z0-9_]+)\.sql$/;
const GENESIS_VERSION = '0001';
const SCHEMA_MIGRATIONS_DDL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at INTEGER NOT NULL
);
`;
const INSERT_MIGRATION_SQL = `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`;
function sha256Hex(body) {
return createHash('sha256').update(body).digest('hex');
}
function tableExists(db, name) {
const row = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)
.get(name);
return row !== undefined;
}
export function listMigrations(migrationsDir) {
if (!existsSync(migrationsDir)) {
throw new Error(`migrations directory not found: ${migrationsDir}`);
}
const entries = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
const files = [];
for (const filename of entries.sort()) {
const match = FILENAME_RE.exec(filename);
if (!match) {
throw new Error(`invalid migration filename: ${filename} (must match NNNN_name.sql)`);
}
const version = match[1];
const name = filename.replace(/\.sql$/, '');
const body = readFileSync(join(migrationsDir, filename), 'utf8');
files.push({ version, name, checksum: sha256Hex(body), body });
}
return files;
}
export function readAppliedRows(db) {
if (!tableExists(db, 'schema_migrations')) {
return [];
}
return db
.prepare(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`)
.all();
}
export function applyMigrations(db, migrationsDir, opts = {}) {
const log = opts.logger ?? (() => { });
const probeTable = opts.genesisProbeTable ?? 'users';
const files = listMigrations(migrationsDir);
if (files.length === 0) {
return { applied: 0, pending: 0, alreadyApplied: 0, stamped: [] };
}
const schemaMigrationsExists = tableExists(db, 'schema_migrations');
const genesisFile = files.find((f) => f.version === GENESIS_VERSION);
const shouldStampGenesis = !schemaMigrationsExists &&
genesisFile !== undefined &&
(opts.stampGenesis === true || tableExists(db, probeTable));
db.exec(SCHEMA_MIGRATIONS_DDL);
const insertRow = db.prepare(INSERT_MIGRATION_SQL);
const stamped = [];
if (shouldStampGenesis && genesisFile) {
const reason = opts.stampGenesis
? 'stampGenesis option set'
: `detected pre-existing '${probeTable}' table`;
log(`WARN genesis-stamp: marked ${genesisFile.name} as applied without executing (${reason})`);
insertRow.run(genesisFile.version, genesisFile.name, genesisFile.checksum, Date.now());
stamped.push(genesisFile.version);
}
const appliedByVersion = new Map(readAppliedRows(db).map((r) => [r.version, r]));
let applied = 0;
let alreadyApplied = 0;
for (const file of files) {
const existing = appliedByVersion.get(file.version);
if (existing) {
if (existing.checksum !== file.checksum) {
throw new Error(`migration checksum mismatch: ${file.name}.sql (expected ${existing.checksum}, got ${file.checksum})`);
}
alreadyApplied += 1;
continue;
}
const runMigration = db.transaction(() => {
db.exec(file.body);
insertRow.run(file.version, file.name, file.checksum, Date.now());
});
runMigration();
log(`applied migration ${file.name}`);
applied += 1;
}
return { applied, pending: 0, alreadyApplied, stamped };
}
export function stampMigration(db, migrationsDir, version) {
const files = listMigrations(migrationsDir);
const file = files.find((f) => f.version === version);
if (!file) {
throw new Error(`migration not found for version: ${version}`);
}
db.exec(SCHEMA_MIGRATIONS_DDL);
const existing = db
.prepare(`SELECT 1 FROM schema_migrations WHERE version = ?`)
.get(file.version);
if (existing) {
throw new Error(`migration already applied: ${file.name}`);
}
db.prepare(INSERT_MIGRATION_SQL).run(file.version, file.name, file.checksum, Date.now());
return file;
}

1823
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "bchen-sqlite-migrate",
"version": "0.1.0",
"description": "Lightweight SQLite migration runner for better-sqlite3 — numbered SQL files, sha256 checksums, idempotent re-apply, genesis-stamping.",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./cli": {
"types": "./dist/cli.d.ts",
"import": "./dist/cli.js"
}
},
"files": ["dist", "README.md", "LICENSE"],
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"better-sqlite3": ">=11 <13"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22",
"better-sqlite3": "12.6.2",
"typescript": "^5.5",
"vitest": "^4.0.0"
},
"repository": {
"type": "git",
"url": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git"
},
"keywords": ["sqlite", "migration", "better-sqlite3", "schema-migrations"]
}

107
src/cli.ts Normal file
View File

@@ -0,0 +1,107 @@
import type Database from 'better-sqlite3';
import {
applyMigrations,
listMigrations,
readAppliedRows,
stampMigration,
} from './migrate.js';
export type DbCommand = 'migrate' | 'status' | 'stamp';
export interface RunCliOptions {
/** Caller-controlled DB open. Wrapper closes it before returning. */
openDb: () => Database.Database;
migrationsDir: string;
command: DbCommand;
/** Required for `stamp`. */
version?: string;
/** Default false. Forwarded to applyMigrations. */
stampGenesis?: boolean;
/** Default 'users'. Forwarded to applyMigrations. */
genesisProbeTable?: string;
/** Default process.stdout / process.stderr. Wired for tests. */
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
}
export function runMigrateCli(opts: RunCliOptions): number {
const stdout = opts.stdout ?? process.stdout;
const stderr = opts.stderr ?? process.stderr;
if (opts.command === 'stamp') {
if (!opts.version) {
stderr.write('error: version required\n');
return 1;
}
const db = opts.openDb();
try {
const file = stampMigration(db, opts.migrationsDir, opts.version);
stdout.write(`stamped:${file.version}\n`);
db.close();
return 0;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
stderr.write(`error: ${msg}\n`);
db.close();
return 1;
}
}
if (opts.command === 'status') {
const db = opts.openDb();
const files = listMigrations(opts.migrationsDir);
const appliedByVersion = new Map(
readAppliedRows(db).map((r) => [r.version, r]),
);
let exitCode = 0;
const pending: string[] = [];
const mismatched: string[] = [];
for (const file of files) {
const applied = appliedByVersion.get(file.version);
if (!applied) {
pending.push(file.version);
stdout.write(`pending:${file.version}\n`);
continue;
}
if (applied.checksum !== file.checksum) {
mismatched.push(file.version);
stdout.write(`checksum-mismatch:${file.version}\n`);
exitCode = 2;
continue;
}
stdout.write(`applied:${file.version}\n`);
}
if (pending.length === 0 && mismatched.length === 0) {
stdout.write(`pending:none\n`);
}
db.close();
return exitCode;
}
// migrate
const db = opts.openDb();
const summary = applyMigrations(db, opts.migrationsDir, {
stampGenesis: opts.stampGenesis,
genesisProbeTable: opts.genesisProbeTable,
logger: (msg) => stdout.write(`${msg}\n`),
});
if (summary.stamped.length > 0) {
stdout.write(`stamped:${summary.stamped.join(',')}\n`);
}
if (summary.applied === 0 && summary.stamped.length === 0) {
stdout.write(`pending:none\n`);
} else if (summary.applied > 0) {
stdout.write(`applied:${summary.applied}\n`);
}
stdout.write(
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
);
db.close();
return 0;
}

12
src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export {
applyMigrations,
listMigrations,
readAppliedRows,
stampMigration,
} from './migrate.js';
export type {
ApplyOptions,
MigrationFile,
MigrationRow,
MigrationSummary,
} from './migrate.js';

176
src/migrate.ts Normal file
View File

@@ -0,0 +1,176 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
export interface MigrationFile {
version: string;
name: string;
checksum: string;
body: string;
}
export interface MigrationRow {
version: string;
name: string;
checksum: string;
applied_at: number;
}
export interface MigrationSummary {
applied: number;
pending: number;
alreadyApplied: number;
stamped: string[];
}
export interface ApplyOptions {
logger?: (msg: string) => void;
stampGenesis?: boolean;
/** Default 'users'. Probe table that decides "is this a pre-existing prod DB needing genesis-stamping?". */
genesisProbeTable?: string;
}
const FILENAME_RE = /^(\d{4,})_([a-z0-9_]+)\.sql$/;
const GENESIS_VERSION = '0001';
const SCHEMA_MIGRATIONS_DDL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at INTEGER NOT NULL
);
`;
const INSERT_MIGRATION_SQL = `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`;
function sha256Hex(body: string): string {
return createHash('sha256').update(body).digest('hex');
}
function tableExists(db: Database.Database, name: string): boolean {
const row = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)
.get(name);
return row !== undefined;
}
export function listMigrations(migrationsDir: string): MigrationFile[] {
if (!existsSync(migrationsDir)) {
throw new Error(`migrations directory not found: ${migrationsDir}`);
}
const entries = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
const files: MigrationFile[] = [];
for (const filename of entries.sort()) {
const match = FILENAME_RE.exec(filename);
if (!match) {
throw new Error(`invalid migration filename: ${filename} (must match NNNN_name.sql)`);
}
const version = match[1];
const name = filename.replace(/\.sql$/, '');
const body = readFileSync(join(migrationsDir, filename), 'utf8');
files.push({ version, name, checksum: sha256Hex(body), body });
}
return files;
}
export function readAppliedRows(db: Database.Database): MigrationRow[] {
if (!tableExists(db, 'schema_migrations')) {
return [];
}
return db
.prepare(
`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
)
.all() as MigrationRow[];
}
export function applyMigrations(
db: Database.Database,
migrationsDir: string,
opts: ApplyOptions = {},
): MigrationSummary {
const log = opts.logger ?? (() => {});
const probeTable = opts.genesisProbeTable ?? 'users';
const files = listMigrations(migrationsDir);
if (files.length === 0) {
return { applied: 0, pending: 0, alreadyApplied: 0, stamped: [] };
}
const schemaMigrationsExists = tableExists(db, 'schema_migrations');
const genesisFile = files.find((f) => f.version === GENESIS_VERSION);
const shouldStampGenesis =
!schemaMigrationsExists &&
genesisFile !== undefined &&
(opts.stampGenesis === true || tableExists(db, probeTable));
db.exec(SCHEMA_MIGRATIONS_DDL);
const insertRow = db.prepare(INSERT_MIGRATION_SQL);
const stamped: string[] = [];
if (shouldStampGenesis && genesisFile) {
const reason = opts.stampGenesis
? 'stampGenesis option set'
: `detected pre-existing '${probeTable}' table`;
log(
`WARN genesis-stamp: marked ${genesisFile.name} as applied without executing (${reason})`,
);
insertRow.run(genesisFile.version, genesisFile.name, genesisFile.checksum, Date.now());
stamped.push(genesisFile.version);
}
const appliedByVersion = new Map(readAppliedRows(db).map((r) => [r.version, r]));
let applied = 0;
let alreadyApplied = 0;
for (const file of files) {
const existing = appliedByVersion.get(file.version);
if (existing) {
if (existing.checksum !== file.checksum) {
throw new Error(
`migration checksum mismatch: ${file.name}.sql (expected ${existing.checksum}, got ${file.checksum})`,
);
}
alreadyApplied += 1;
continue;
}
const runMigration = db.transaction(() => {
db.exec(file.body);
insertRow.run(file.version, file.name, file.checksum, Date.now());
});
runMigration();
log(`applied migration ${file.name}`);
applied += 1;
}
return { applied, pending: 0, alreadyApplied, stamped };
}
export function stampMigration(
db: Database.Database,
migrationsDir: string,
version: string,
): MigrationFile {
const files = listMigrations(migrationsDir);
const file = files.find((f) => f.version === version);
if (!file) {
throw new Error(`migration not found for version: ${version}`);
}
db.exec(SCHEMA_MIGRATIONS_DDL);
const existing = db
.prepare(`SELECT 1 FROM schema_migrations WHERE version = ?`)
.get(file.version);
if (existing) {
throw new Error(`migration already applied: ${file.name}`);
}
db.prepare(INSERT_MIGRATION_SQL).run(
file.version,
file.name,
file.checksum,
Date.now(),
);
return file;
}

157
tests/cli.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import Database from 'better-sqlite3';
import { runMigrateCli } from '../src/cli.ts';
function captureStreams() {
const stdout = new PassThrough();
const stderr = new PassThrough();
const stdoutBuf: Buffer[] = [];
const stderrBuf: Buffer[] = [];
stdout.on('data', (c) => stdoutBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
stderr.on('data', (c) => stderrBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
return {
stdout,
stderr,
get stdoutText() {
return Buffer.concat(stdoutBuf).toString('utf8');
},
get stderrText() {
return Buffer.concat(stderrBuf).toString('utf8');
},
};
}
describe('runMigrateCli', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-cli-test-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("command='migrate' calls applyMigrations and writes expected lines", () => {
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);');
const caps = captureStreams();
const code = runMigrateCli({
openDb: () => new Database(':memory:'),
migrationsDir: tmpDir,
command: 'migrate',
stdout: caps.stdout,
stderr: caps.stderr,
});
expect(code).toBe(0);
expect(caps.stdoutText).toContain('applied:1');
expect(caps.stdoutText).toContain('migrations: 1 applied, 0 pending');
});
it("command='status' emits pending then applied then pending again", () => {
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);');
const sharedDb = new Database(':memory:');
const openDb = () => {
const proxy = new Proxy(sharedDb, {
get(target, prop) {
if (prop === 'close') return () => {};
const v = (target as any)[prop];
return typeof v === 'function' ? v.bind(target) : v;
},
});
return proxy as unknown as Database.Database;
};
const migrateCaps = captureStreams();
const migrateCode = runMigrateCli({
openDb,
migrationsDir: tmpDir,
command: 'migrate',
stdout: migrateCaps.stdout,
stderr: migrateCaps.stderr,
});
expect(migrateCode).toBe(0);
const statusCaps = captureStreams();
const statusCode = runMigrateCli({
openDb,
migrationsDir: tmpDir,
command: 'status',
stdout: statusCaps.stdout,
stderr: statusCaps.stderr,
});
expect(statusCode).toBe(0);
expect(statusCaps.stdoutText).toContain('applied:0001');
writeFileSync(join(tmpDir, '0002_more.sql'), 'CREATE TABLE y (b INTEGER);');
const statusCaps2 = captureStreams();
const statusCode2 = runMigrateCli({
openDb,
migrationsDir: tmpDir,
command: 'status',
stdout: statusCaps2.stdout,
stderr: statusCaps2.stderr,
});
expect(statusCode2).toBe(0);
expect(statusCaps2.stdoutText).toContain('pending:0002');
sharedDb.close();
});
it("command='stamp' marks applied without running body", () => {
writeFileSync(
join(tmpDir, '0001_init.sql'),
'CREATE TABLE only_if_applied (x INTEGER);',
);
const sharedDb = new Database(':memory:');
const openDb = () => {
const proxy = new Proxy(sharedDb, {
get(target, prop) {
if (prop === 'close') return () => {};
const v = (target as any)[prop];
return typeof v === 'function' ? v.bind(target) : v;
},
});
return proxy as unknown as Database.Database;
};
const stampCaps = captureStreams();
const code = runMigrateCli({
openDb,
migrationsDir: tmpDir,
command: 'stamp',
version: '0001',
stdout: stampCaps.stdout,
stderr: stampCaps.stderr,
});
expect(code).toBe(0);
expect(stampCaps.stdoutText).toContain('stamped:0001');
const tableMissing = sharedDb
.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`,
)
.get();
expect(tableMissing).toBeUndefined();
const noVersionCaps = captureStreams();
const noVersionCode = runMigrateCli({
openDb,
migrationsDir: tmpDir,
command: 'stamp',
stdout: noVersionCaps.stdout,
stderr: noVersionCaps.stderr,
});
expect(noVersionCode).toBe(1);
expect(noVersionCaps.stderrText).toContain('error:');
sharedDb.close();
});
});

331
tests/migrate.test.ts Normal file
View File

@@ -0,0 +1,331 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import {
applyMigrations,
listMigrations,
readAppliedRows,
stampMigration,
} from '../src/migrate.ts';
const LEGACY_BOOTSTRAP_SQL = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
username TEXT PRIMARY KEY,
failed_count INTEGER NOT NULL DEFAULT 0,
last_failed_at INTEGER,
locked_until INTEGER
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until);
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT NOT NULL,
client_name TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
allowed_grants TEXT NOT NULL,
allowed_scopes TEXT NOT NULL,
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_used_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
revoked_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_owner ON oauth_clients(owner_user_id);
CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
expires_at INTEGER NOT NULL,
redeemed_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_authorization_codes(expires_at);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_authorization_codes(client_id);
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
token_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
expires_at INTEGER NOT NULL,
revoked_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_at_expires ON oauth_access_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_oauth_at_owner ON oauth_access_tokens(client_id, user_id);
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
token_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
expires_at INTEGER NOT NULL,
revoked_at INTEGER,
replaced_by_token_hash TEXT,
family_id TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_rt_expires ON oauth_refresh_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_oauth_rt_family ON oauth_refresh_tokens(family_id);
CREATE TABLE IF NOT EXISTS mfa_profiles (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
secret_encrypted TEXT NOT NULL,
enrolled_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, code_hash)
);
CREATE INDEX IF NOT EXISTS idx_mfa_recovery_user
ON mfa_recovery_codes(user_id) WHERE used_at IS NULL;
CREATE TABLE IF NOT EXISTS mfa_attempts (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
failed_count INTEGER NOT NULL DEFAULT 0,
last_failed_at INTEGER,
locked_until INTEGER
);
CREATE INDEX IF NOT EXISTS idx_mfa_attempts_locked ON mfa_attempts(locked_until);
`;
interface SchemaObject {
type: string;
name: string;
sql: string | null;
}
function normalizeSql(sql: string | null): string | null {
if (sql === null) return null;
return sql.replace(/\s+/g, ' ').trim();
}
function snapshotSchema(db: Database.Database): SchemaObject[] {
const rows = db
.prepare(
`SELECT type, name, sql FROM sqlite_master
WHERE type IN ('table','index')
AND name NOT LIKE 'sqlite_%'
AND name != 'schema_migrations'
ORDER BY type, name`,
)
.all() as SchemaObject[];
return rows.map((r) => ({ ...r, sql: normalizeSql(r.sql) }));
}
describe('migrate', () => {
let tmpDir: string;
let db: Database.Database;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-test-'));
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
});
afterEach(() => {
db.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it('fresh apply produces byte-equivalent schema to legacy bootstrap', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
const legacyDb = new Database(':memory:');
legacyDb.pragma('foreign_keys = ON');
legacyDb.exec(LEGACY_BOOTSTRAP_SQL);
const legacySnapshot = snapshotSchema(legacyDb);
legacyDb.close();
const summary = applyMigrations(db, tmpDir);
expect(summary.applied).toBe(1);
expect(summary.alreadyApplied).toBe(0);
expect(summary.pending).toBe(0);
expect(summary.stamped).toEqual([]);
const newSnapshot = snapshotSchema(db);
expect(newSnapshot).toEqual(legacySnapshot);
});
it('is idempotent on re-apply', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
applyMigrations(db, tmpDir);
const summary = applyMigrations(db, tmpDir);
expect(summary.applied).toBe(0);
expect(summary.alreadyApplied).toBe(1);
expect(summary.pending).toBe(0);
expect(summary.stamped).toEqual([]);
const rows = readAppliedRows(db);
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe('0001');
});
it('aborts on checksum mismatch', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE original (x INTEGER);');
applyMigrations(db, tmpDir);
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE tampered (x INTEGER);');
expect(() => applyMigrations(db, tmpDir)).toThrow(/checksum mismatch.*0001_init/);
});
it('stamps genesis on pre-existing DB with users table', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);`);
const warnings: string[] = [];
const summary = applyMigrations(db, tmpDir, {
logger: (msg) => warnings.push(msg),
});
expect(summary.stamped).toEqual(['0001']);
expect(summary.applied).toBe(0);
expect(summary.alreadyApplied).toBe(1);
expect(warnings.some((w) => w.includes('genesis-stamp'))).toBe(true);
const rows = readAppliedRows(db);
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe('0001');
const files = listMigrations(tmpDir);
expect(rows[0].checksum).toBe(files[0].checksum);
});
it('rolls back when a migration fails partway', () => {
writeFileSync(join(tmpDir, '0001_ok.sql'), 'CREATE TABLE a (x INTEGER);');
writeFileSync(
join(tmpDir, '0002_bad.sql'),
'CREATE TABLE b (x INTEGER); CREATE TABLE INVALID_SYNTAX_HERE',
);
expect(() => applyMigrations(db, tmpDir)).toThrow();
const rows = readAppliedRows(db);
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe('0001');
const aExists = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='a'`)
.get();
expect(aExists).toBeDefined();
const bExists = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='b'`)
.get();
expect(bExists).toBeUndefined();
});
it('stampMigration marks version applied without running body', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE only_if_applied (x INTEGER);');
const file = stampMigration(db, tmpDir, '0001');
expect(file.version).toBe('0001');
const rows = readAppliedRows(db);
expect(rows).toHaveLength(1);
const tableMissing = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`)
.get();
expect(tableMissing).toBeUndefined();
});
it('stampMigration refuses double-stamp', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);');
stampMigration(db, tmpDir, '0001');
expect(() => stampMigration(db, tmpDir, '0001')).toThrow(/already applied/);
});
it('rejects invalid migration filenames', () => {
writeFileSync(join(tmpDir, 'notamigration.sql'), 'SELECT 1;');
expect(() => applyMigrations(db, tmpDir)).toThrow(/invalid migration filename/);
});
it('honors stampGenesis option even without users table', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
const summary = applyMigrations(db, tmpDir, { stampGenesis: true });
expect(summary.stamped).toEqual(['0001']);
expect(summary.applied).toBe(0);
const usersExists = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`)
.get();
expect(usersExists).toBeUndefined();
});
// New probe-table specs
it('probe-table-not-present + stampGenesis=false runs first migration normally', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE users (id TEXT PRIMARY KEY);');
const summary = applyMigrations(db, tmpDir, { stampGenesis: false });
expect(summary.stamped).toEqual([]);
expect(summary.applied).toBe(1);
expect(summary.alreadyApplied).toBe(0);
const usersExists = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`)
.get();
expect(usersExists).toBeDefined();
const rows = readAppliedRows(db);
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe('0001');
});
it('genesisProbeTable=undefined defaults to users (authd back-compat)', () => {
writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
const warnings: string[] = [];
const summary = applyMigrations(db, tmpDir, {
logger: (msg) => warnings.push(msg),
});
expect(summary.stamped).toEqual(['0001']);
expect(summary.applied).toBe(0);
expect(summary.alreadyApplied).toBe(1);
expect(
warnings.some((w) => w.includes('genesis-stamp') && w.includes("'users'")),
).toBe(true);
});
it("genesisProbeTable='sessions' stamps a buchinese-shaped DB", () => {
writeFileSync(
join(tmpDir, '0001_init.sql'),
`CREATE TABLE users (id TEXT PRIMARY KEY); CREATE TABLE sessions (id TEXT PRIMARY KEY);`,
);
db.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY);`);
const warnings: string[] = [];
const summary = applyMigrations(db, tmpDir, {
genesisProbeTable: 'sessions',
logger: (msg) => warnings.push(msg),
});
expect(summary.stamped).toEqual(['0001']);
expect(summary.applied).toBe(0);
const usersExists = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`)
.get();
expect(usersExists).toBeUndefined();
expect(
warnings.some((w) => w.includes('genesis-stamp') && w.includes("'sessions'")),
).toBe(true);
});
});

14
tsconfig.build.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"outDir": "./dist",
"rootDir": "./src",
"allowImportingTsExtensions": false
},
"include": ["src/**/*"],
"exclude": ["tests/**/*", "node_modules", "dist"]
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "tests/**/*"]
}

5
vitest.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { globals: false, environment: 'node' },
});