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
All checks were successful
ci / test (push) Successful in 11s
This commit was merged in pull request #1.
This commit is contained in:
21
.gitea/workflows/ci.yml
Normal file
21
.gitea/workflows/ci.yml
Normal 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
3
.gitignore
vendored
@@ -136,3 +136,6 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
|
# package: ship pre-compiled dist/
|
||||||
|
!/dist/
|
||||||
|
|||||||
162
README.md
162
README.md
@@ -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
18
dist/cli.d.ts
vendored
Normal 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
71
dist/cli.js
vendored
Normal 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
2
dist/index.d.ts
vendored
Normal 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
1
dist/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { applyMigrations, listMigrations, readAppliedRows, stampMigration, } from './migrate.js';
|
||||||
29
dist/migrate.d.ts
vendored
Normal file
29
dist/migrate.d.ts
vendored
Normal 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
110
dist/migrate.js
vendored
Normal 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
1823
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
107
src/cli.ts
Normal 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
12
src/index.ts
Normal 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
176
src/migrate.ts
Normal 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
157
tests/cli.test.ts
Normal 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
331
tests/migrate.test.ts
Normal 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
14
tsconfig.build.json
Normal 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
16
tsconfig.json
Normal 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
5
vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { globals: false, environment: 'node' },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user