feat: adopt bchen-sqlite-migrate package; replace inline SCHEMA_DDL
Phase 3 of the cross-project sqlite-migrate adoption — port nanodrop to
consume bchen-sqlite-migrate@v0.1.0. Replaces the inline db.exec(...)
block in src/db/schema.ts with applyMigrations(db, MIGRATIONS_DIR,
{ genesisProbeTable: 'users' }).
The genesis-probe (table 'users' exists) handles pre-existing prod DBs
automatically — first deploy after merge stamps 0001_init as applied
without re-executing, subsequent boots are no-ops.
Adds three npm scripts (db:migrate, db:status, db:stamp) and a
byte-stability test pinning sha256(0001_init.sql) so the migration is
treated as immutable history.
This commit is contained in:
26
src/db/migrations/0001_init.sql
Normal file
26
src/db/migrations/0001_init.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
stored_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
username TEXT PRIMARY KEY,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failed_at TEXT,
|
||||
locked_until TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked_until
|
||||
ON login_attempts(locked_until);
|
||||
@@ -1,38 +1,27 @@
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { applyMigrations } from 'bchen-sqlite-migrate';
|
||||
|
||||
export const MIGRATIONS_DIR = path.resolve(import.meta.dirname, 'migrations');
|
||||
|
||||
export function applySchema(db: Database.Database): void {
|
||||
applyMigrations(db, MIGRATIONS_DIR, { genesisProbeTable: 'users' });
|
||||
}
|
||||
|
||||
export function initDb(dbPath: string): Database.Database {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
stored_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
username TEXT PRIMARY KEY,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failed_at TEXT,
|
||||
locked_until TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked_until
|
||||
ON login_attempts(locked_until);
|
||||
`);
|
||||
const stampGenesis = process.env.DB_MIGRATIONS_STAMP_GENESIS === '1';
|
||||
const summary = applyMigrations(db, MIGRATIONS_DIR, {
|
||||
stampGenesis,
|
||||
genesisProbeTable: 'users',
|
||||
logger: (msg) => process.stdout.write(`${msg}\n`),
|
||||
});
|
||||
process.stdout.write(
|
||||
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
|
||||
);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
21
src/scripts/_db-cli.ts
Normal file
21
src/scripts/_db-cli.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { loadConfig } from '../config.ts';
|
||||
import { MIGRATIONS_DIR } from '../db/schema.ts';
|
||||
|
||||
export interface DbCliContext {
|
||||
db: Database.Database;
|
||||
migrationsDir: string;
|
||||
}
|
||||
|
||||
export function openDbFromConfig(): DbCliContext {
|
||||
const config = loadConfig();
|
||||
mkdirSync(dirname(config.dbPath), { recursive: true });
|
||||
|
||||
const db = new Database(config.dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
return { db, migrationsDir: MIGRATIONS_DIR };
|
||||
}
|
||||
26
src/scripts/db-migrate.ts
Normal file
26
src/scripts/db-migrate.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { applyMigrations } from 'bchen-sqlite-migrate';
|
||||
import { openDbFromConfig } from './_db-cli.ts';
|
||||
|
||||
const { db, migrationsDir } = openDbFromConfig();
|
||||
|
||||
const stampGenesis = process.env.DB_MIGRATIONS_STAMP_GENESIS === '1';
|
||||
const summary = applyMigrations(db, migrationsDir, {
|
||||
stampGenesis,
|
||||
genesisProbeTable: 'users',
|
||||
logger: (msg) => process.stdout.write(`${msg}\n`),
|
||||
});
|
||||
|
||||
if (summary.stamped.length > 0) {
|
||||
process.stdout.write(`stamped:${summary.stamped.join(',')}\n`);
|
||||
}
|
||||
if (summary.applied === 0 && summary.stamped.length === 0) {
|
||||
process.stdout.write(`pending:none\n`);
|
||||
} else if (summary.applied > 0) {
|
||||
process.stdout.write(`applied:${summary.applied}\n`);
|
||||
}
|
||||
process.stdout.write(
|
||||
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
|
||||
);
|
||||
|
||||
db.close();
|
||||
process.exit(0);
|
||||
22
src/scripts/db-stamp.ts
Normal file
22
src/scripts/db-stamp.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { stampMigration } from 'bchen-sqlite-migrate';
|
||||
import { openDbFromConfig } from './_db-cli.ts';
|
||||
|
||||
const version = process.argv[2];
|
||||
if (!version) {
|
||||
process.stderr.write('Usage: npm run db:stamp -- <version>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { db, migrationsDir } = openDbFromConfig();
|
||||
|
||||
try {
|
||||
const file = stampMigration(db, migrationsDir, version);
|
||||
process.stdout.write(`stamped:${file.version}\n`);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`error: ${msg}\n`);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
34
src/scripts/db-status.ts
Normal file
34
src/scripts/db-status.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { listMigrations, readAppliedRows } from 'bchen-sqlite-migrate';
|
||||
import { openDbFromConfig } from './_db-cli.ts';
|
||||
|
||||
const { db, migrationsDir } = openDbFromConfig();
|
||||
|
||||
const files = listMigrations(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);
|
||||
process.stdout.write(`pending:${file.version}\n`);
|
||||
continue;
|
||||
}
|
||||
if (applied.checksum !== file.checksum) {
|
||||
mismatched.push(file.version);
|
||||
process.stdout.write(`checksum-mismatch:${file.version}\n`);
|
||||
exitCode = 2;
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(`applied:${file.version}\n`);
|
||||
}
|
||||
|
||||
if (pending.length === 0 && mismatched.length === 0) {
|
||||
process.stdout.write(`pending:none\n`);
|
||||
}
|
||||
|
||||
db.close();
|
||||
process.exit(exitCode);
|
||||
Reference in New Issue
Block a user