feat: v0.1.0 — sqlite-migrate package extraction from authd #1
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;
|
||||||
113
dist/migrate.js
vendored
Normal file
113
dist/migrate.js
vendored
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user