From accd3ca3404cd93e7ac28499e8f60e6b0b1dcdd5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:17:02 -0700 Subject: [PATCH] build: regenerate dist/ for v0.1.0 Pre-compiled ESM JS + .d.ts from tsconfig.build.json. Consumers install via 'git+https://gitea.bchen.dev/...#v0.1.0' so the built output must travel with the source. CI verifies in-sync via 'git diff --exit-code dist/'. --- dist/cli.d.ts | 18 ++++++++ dist/cli.js | 71 +++++++++++++++++++++++++++++ dist/index.d.ts | 2 + dist/index.js | 1 + dist/migrate.d.ts | 29 ++++++++++++ dist/migrate.js | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+) create mode 100644 dist/cli.d.ts create mode 100644 dist/cli.js create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/migrate.d.ts create mode 100644 dist/migrate.js diff --git a/dist/cli.d.ts b/dist/cli.d.ts new file mode 100644 index 0000000..53675e8 --- /dev/null +++ b/dist/cli.d.ts @@ -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; diff --git a/dist/cli.js b/dist/cli.js new file mode 100644 index 0000000..45828c0 --- /dev/null +++ b/dist/cli.js @@ -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; +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..42fa155 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,2 @@ +export { applyMigrations, listMigrations, readAppliedRows, stampMigration, } from './migrate.js'; +export type { ApplyOptions, MigrationFile, MigrationRow, MigrationSummary, } from './migrate.js'; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..3a4e699 --- /dev/null +++ b/dist/index.js @@ -0,0 +1 @@ +export { applyMigrations, listMigrations, readAppliedRows, stampMigration, } from './migrate.js'; diff --git a/dist/migrate.d.ts b/dist/migrate.d.ts new file mode 100644 index 0000000..99ad8b1 --- /dev/null +++ b/dist/migrate.d.ts @@ -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; diff --git a/dist/migrate.js b/dist/migrate.js new file mode 100644 index 0000000..3aefa21 --- /dev/null +++ b/dist/migrate.js @@ -0,0 +1,113 @@ +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 + ); +`; +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 stamped = []; + const genesisFile = files.find((f) => f.version === GENESIS_VERSION); + const shouldStampGenesis = !schemaMigrationsExists && + genesisFile !== undefined && + (opts.stampGenesis === true || tableExists(db, probeTable)); + if (shouldStampGenesis && genesisFile) { + db.exec(SCHEMA_MIGRATIONS_DDL); + const reason = opts.stampGenesis + ? 'stampGenesis option set' + : `detected pre-existing '${probeTable}' table`; + log(`WARN genesis-stamp: marked ${genesisFile.name} as applied without executing (${reason})`); + db.prepare(`INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`).run(genesisFile.version, genesisFile.name, genesisFile.checksum, Date.now()); + stamped.push(genesisFile.version); + } + else { + db.exec(SCHEMA_MIGRATIONS_DDL); + } + const appliedRows = readAppliedRows(db); + const appliedByVersion = new Map(appliedRows.map((r) => [r.version, r])); + let applied = 0; + let alreadyApplied = 0; + const insertRow = db.prepare(`INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`); + 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 version, name, checksum, applied_at FROM schema_migrations WHERE version = ?`) + .get(file.version); + if (existing) { + throw new Error(`migration already applied: ${file.name}`); + } + db.prepare(`INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`).run(file.version, file.name, file.checksum, Date.now()); + return file; +}