diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..6e0c78a --- /dev/null +++ b/src/cli.ts @@ -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; +}