Merge pull request 'feat: adopt bchen-sqlite-migrate package; replace inline SCHEMA_DDL' (#9) from feat/adopt-sqlite-migrate into main
Some checks failed
Deploy to birb co. production / deploy (push) Failing after 10s

This commit was merged in pull request #9.
This commit is contained in:
2026-05-12 15:08:33 +00:00
9 changed files with 994 additions and 536 deletions

1332
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"register-user": "tsx src/cli/register-user.ts" "register-user": "tsx src/cli/register-user.ts",
"db:migrate": "tsx src/scripts/db-migrate.ts",
"db:status": "tsx src/scripts/db-status.ts",
"db:stamp": "tsx src/scripts/db-stamp.ts"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@@ -20,6 +23,7 @@
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"bchen-sqlite-migrate": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"fastify": "^5.7.4", "fastify": "^5.7.4",

View 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);

View File

@@ -1,38 +1,27 @@
import path from 'node:path';
import Database from 'better-sqlite3'; 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 { export function initDb(dbPath: string): Database.Database {
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
db.exec(` const stampGenesis = process.env.DB_MIGRATIONS_STAMP_GENESIS === '1';
CREATE TABLE IF NOT EXISTS users ( const summary = applyMigrations(db, MIGRATIONS_DIR, {
id INTEGER PRIMARY KEY AUTOINCREMENT, stampGenesis,
username TEXT NOT NULL UNIQUE, genesisProbeTable: 'users',
password_hash TEXT NOT NULL, logger: (msg) => process.stdout.write(`${msg}\n`),
created_at TEXT DEFAULT (datetime('now')) });
process.stdout.write(
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
); );
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);
`);
return db; return db;
} }

21
src/scripts/_db-cli.ts Normal file
View 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
View 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
View 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
View 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);

View File

@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
const EXPECTED_0001_SHA256 =
'34f092b4bb8544a48acfee0fad08d51b1b75fedf4ffdfbcb790d2656d0f1d57a';
describe('migrations byte stability', () => {
it('0001_init.sql sha256 is frozen — edit means new migration, not edit-in-place', () => {
const body = readFileSync(
resolve(import.meta.dirname, '..', '..', 'src', 'db', 'migrations', '0001_init.sql'),
'utf8',
);
const actual = createHash('sha256').update(body).digest('hex');
expect(actual).toBe(EXPECTED_0001_SHA256);
});
});