From d2dfa5f61bbf7a046dee40a6701829295f2959c2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:16 -0700 Subject: [PATCH 1/9] feat: add migration runner with genesisProbeTable param Forward-only SQLite migration runner lifted from authd (src/db/migrate.ts, PR #14 / merge ced21ab) and parameterized for cross-project use. The single deliberate API change vs. authd's source: the previously-hardcoded GENESIS_PROBE_TABLE = 'users' is now an ApplyOptions.genesisProbeTable?: string option (default 'users'). --- src/index.ts | 12 ++++ src/migrate.ts | 180 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/index.ts create mode 100644 src/migrate.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6b3ce8b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +export { + applyMigrations, + listMigrations, + readAppliedRows, + stampMigration, +} from './migrate.js'; +export type { + ApplyOptions, + MigrationFile, + MigrationRow, + MigrationSummary, +} from './migrate.js'; diff --git a/src/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..252a7ee --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,180 @@ +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +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; +} + +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: string): string { + return createHash('sha256').update(body).digest('hex'); +} + +function tableExists(db: Database.Database, name: string): boolean { + const row = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`) + .get(name); + return row !== undefined; +} + +export function listMigrations(migrationsDir: string): MigrationFile[] { + if (!existsSync(migrationsDir)) { + throw new Error(`migrations directory not found: ${migrationsDir}`); + } + const entries = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')); + const files: MigrationFile[] = []; + 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: Database.Database): MigrationRow[] { + if (!tableExists(db, 'schema_migrations')) { + return []; + } + return db + .prepare( + `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`, + ) + .all() as MigrationRow[]; +} + +export function applyMigrations( + db: Database.Database, + migrationsDir: string, + opts: ApplyOptions = {}, +): MigrationSummary { + 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: string[] = []; + + 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: Database.Database, + migrationsDir: string, + version: string, +): MigrationFile { + 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) as MigrationRow | undefined; + 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; +} From a4c994ca9a7fe49fa2228d4aa77ebc9cad091fa5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:21 -0700 Subject: [PATCH 2/9] feat: add runMigrateCli wrapper at sub-path './cli' Extracts the union of authd's three CLI scripts (db-migrate.ts, db-status.ts, db-stamp.ts) into one wrapper. Consumers wire a ~5-line shim that injects their own openDb / migrationsDir / config-loading logic. The wrapper returns an exit code rather than calling process.exit, which keeps it testable via PassThrough stream capture. --- src/cli.ts | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/cli.ts 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; +} From 7a7c5adf9277793d8676b604f679506d375f0d0e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:29 -0700 Subject: [PATCH 3/9] test: port migrate suite + add probe-table and CLI specs 15 vitest specs total: - 9 lifted from authd's tests/db/migrate.test.ts. Specs that referenced the on-disk migrations dir now write fixtures into a per-test tmpdir (package ships no migrations dir of its own). - 3 new specs covering the genesisProbeTable parameter (default, custom table name, opt-out via stampGenesis=false). - 3 new specs covering runMigrateCli (migrate / status / stamp) using PassThrough stream capture. --- tests/cli.test.ts | 157 ++++++++++++++++++++ tests/migrate.test.ts | 331 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 tests/cli.test.ts create mode 100644 tests/migrate.test.ts diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..90f5f80 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import Database from 'better-sqlite3'; +import { runMigrateCli } from '../src/cli.ts'; + +function captureStreams() { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdoutBuf: Buffer[] = []; + const stderrBuf: Buffer[] = []; + stdout.on('data', (c) => stdoutBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + stderr.on('data', (c) => stderrBuf.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + return { + stdout, + stderr, + get stdoutText() { + return Buffer.concat(stdoutBuf).toString('utf8'); + }, + get stderrText() { + return Buffer.concat(stderrBuf).toString('utf8'); + }, + }; +} + +describe('runMigrateCli', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-cli-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("command='migrate' calls applyMigrations and writes expected lines", () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);'); + const caps = captureStreams(); + + const code = runMigrateCli({ + openDb: () => new Database(':memory:'), + migrationsDir: tmpDir, + command: 'migrate', + stdout: caps.stdout, + stderr: caps.stderr, + }); + + expect(code).toBe(0); + expect(caps.stdoutText).toContain('applied:1'); + expect(caps.stdoutText).toContain('migrations: 1 applied, 0 pending'); + }); + + it("command='status' emits pending then applied then pending again", () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);'); + + const sharedDb = new Database(':memory:'); + const openDb = () => { + const proxy = new Proxy(sharedDb, { + get(target, prop) { + if (prop === 'close') return () => {}; + const v = (target as any)[prop]; + return typeof v === 'function' ? v.bind(target) : v; + }, + }); + return proxy as unknown as Database.Database; + }; + + const migrateCaps = captureStreams(); + const migrateCode = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'migrate', + stdout: migrateCaps.stdout, + stderr: migrateCaps.stderr, + }); + expect(migrateCode).toBe(0); + + const statusCaps = captureStreams(); + const statusCode = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'status', + stdout: statusCaps.stdout, + stderr: statusCaps.stderr, + }); + expect(statusCode).toBe(0); + expect(statusCaps.stdoutText).toContain('applied:0001'); + + writeFileSync(join(tmpDir, '0002_more.sql'), 'CREATE TABLE y (b INTEGER);'); + const statusCaps2 = captureStreams(); + const statusCode2 = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'status', + stdout: statusCaps2.stdout, + stderr: statusCaps2.stderr, + }); + expect(statusCode2).toBe(0); + expect(statusCaps2.stdoutText).toContain('pending:0002'); + + sharedDb.close(); + }); + + it("command='stamp' marks applied without running body", () => { + writeFileSync( + join(tmpDir, '0001_init.sql'), + 'CREATE TABLE only_if_applied (x INTEGER);', + ); + + const sharedDb = new Database(':memory:'); + const openDb = () => { + const proxy = new Proxy(sharedDb, { + get(target, prop) { + if (prop === 'close') return () => {}; + const v = (target as any)[prop]; + return typeof v === 'function' ? v.bind(target) : v; + }, + }); + return proxy as unknown as Database.Database; + }; + + const stampCaps = captureStreams(); + const code = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'stamp', + version: '0001', + stdout: stampCaps.stdout, + stderr: stampCaps.stderr, + }); + expect(code).toBe(0); + expect(stampCaps.stdoutText).toContain('stamped:0001'); + + const tableMissing = sharedDb + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`, + ) + .get(); + expect(tableMissing).toBeUndefined(); + + const noVersionCaps = captureStreams(); + const noVersionCode = runMigrateCli({ + openDb, + migrationsDir: tmpDir, + command: 'stamp', + stdout: noVersionCaps.stdout, + stderr: noVersionCaps.stderr, + }); + expect(noVersionCode).toBe(1); + expect(noVersionCaps.stderrText).toContain('error:'); + + sharedDb.close(); + }); +}); diff --git a/tests/migrate.test.ts b/tests/migrate.test.ts new file mode 100644 index 0000000..0403d13 --- /dev/null +++ b/tests/migrate.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { + applyMigrations, + listMigrations, + readAppliedRows, + stampMigration, +} from '../src/migrate.ts'; + +const LEGACY_BOOTSTRAP_SQL = ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS login_attempts ( + username TEXT PRIMARY KEY, + failed_count INTEGER NOT NULL DEFAULT 0, + last_failed_at INTEGER, + locked_until INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until); + + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret_hash TEXT NOT NULL, + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + allowed_grants TEXT NOT NULL, + allowed_scopes TEXT NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_used_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_oauth_clients_owner ON oauth_clients(owner_user_id); + + CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + code_challenge TEXT NOT NULL, + code_challenge_method TEXT NOT NULL DEFAULT 'S256', + expires_at INTEGER NOT NULL, + redeemed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_authorization_codes(expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_authorization_codes(client_id); + + CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_at_expires ON oauth_access_tokens(expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_at_owner ON oauth_access_tokens(client_id, user_id); + + CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + replaced_by_token_hash TEXT, + family_id TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_rt_expires ON oauth_refresh_tokens(expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_rt_family ON oauth_refresh_tokens(family_id); + + CREATE TABLE IF NOT EXISTS mfa_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + secret_encrypted TEXT NOT NULL, + enrolled_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS mfa_recovery_codes ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, code_hash) + ); + CREATE INDEX IF NOT EXISTS idx_mfa_recovery_user + ON mfa_recovery_codes(user_id) WHERE used_at IS NULL; + + CREATE TABLE IF NOT EXISTS mfa_attempts ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + failed_count INTEGER NOT NULL DEFAULT 0, + last_failed_at INTEGER, + locked_until INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_mfa_attempts_locked ON mfa_attempts(locked_until); + `; + +interface SchemaObject { + type: string; + name: string; + sql: string | null; +} + +function normalizeSql(sql: string | null): string | null { + if (sql === null) return null; + return sql.replace(/\s+/g, ' ').trim(); +} + +function snapshotSchema(db: Database.Database): SchemaObject[] { + const rows = db + .prepare( + `SELECT type, name, sql FROM sqlite_master + WHERE type IN ('table','index') + AND name NOT LIKE 'sqlite_%' + AND name != 'schema_migrations' + ORDER BY type, name`, + ) + .all() as SchemaObject[]; + return rows.map((r) => ({ ...r, sql: normalizeSql(r.sql) })); +} + +describe('migrate', () => { + let tmpDir: string; + let db: Database.Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'sqlite-migrate-test-')); + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + }); + + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('fresh apply produces byte-equivalent schema to legacy bootstrap', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + + const legacyDb = new Database(':memory:'); + legacyDb.pragma('foreign_keys = ON'); + legacyDb.exec(LEGACY_BOOTSTRAP_SQL); + const legacySnapshot = snapshotSchema(legacyDb); + legacyDb.close(); + + const summary = applyMigrations(db, tmpDir); + expect(summary.applied).toBe(1); + expect(summary.alreadyApplied).toBe(0); + expect(summary.pending).toBe(0); + expect(summary.stamped).toEqual([]); + + const newSnapshot = snapshotSchema(db); + expect(newSnapshot).toEqual(legacySnapshot); + }); + + it('is idempotent on re-apply', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + applyMigrations(db, tmpDir); + const summary = applyMigrations(db, tmpDir); + expect(summary.applied).toBe(0); + expect(summary.alreadyApplied).toBe(1); + expect(summary.pending).toBe(0); + expect(summary.stamped).toEqual([]); + + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + }); + + it('aborts on checksum mismatch', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE original (x INTEGER);'); + applyMigrations(db, tmpDir); + + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE tampered (x INTEGER);'); + + expect(() => applyMigrations(db, tmpDir)).toThrow(/checksum mismatch.*0001_init/); + }); + + it('stamps genesis on pre-existing DB with users table', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);`); + + const warnings: string[] = []; + const summary = applyMigrations(db, tmpDir, { + logger: (msg) => warnings.push(msg), + }); + + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + expect(summary.alreadyApplied).toBe(1); + expect(warnings.some((w) => w.includes('genesis-stamp'))).toBe(true); + + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + + const files = listMigrations(tmpDir); + expect(rows[0].checksum).toBe(files[0].checksum); + }); + + it('rolls back when a migration fails partway', () => { + writeFileSync(join(tmpDir, '0001_ok.sql'), 'CREATE TABLE a (x INTEGER);'); + writeFileSync( + join(tmpDir, '0002_bad.sql'), + 'CREATE TABLE b (x INTEGER); CREATE TABLE INVALID_SYNTAX_HERE', + ); + + expect(() => applyMigrations(db, tmpDir)).toThrow(); + + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + + const aExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='a'`) + .get(); + expect(aExists).toBeDefined(); + + const bExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='b'`) + .get(); + expect(bExists).toBeUndefined(); + }); + + it('stampMigration marks version applied without running body', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE only_if_applied (x INTEGER);'); + const file = stampMigration(db, tmpDir, '0001'); + expect(file.version).toBe('0001'); + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + const tableMissing = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='only_if_applied'`) + .get(); + expect(tableMissing).toBeUndefined(); + }); + + it('stampMigration refuses double-stamp', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE x (a INTEGER);'); + stampMigration(db, tmpDir, '0001'); + expect(() => stampMigration(db, tmpDir, '0001')).toThrow(/already applied/); + }); + + it('rejects invalid migration filenames', () => { + writeFileSync(join(tmpDir, 'notamigration.sql'), 'SELECT 1;'); + expect(() => applyMigrations(db, tmpDir)).toThrow(/invalid migration filename/); + }); + + it('honors stampGenesis option even without users table', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + const summary = applyMigrations(db, tmpDir, { stampGenesis: true }); + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + + const usersExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`) + .get(); + expect(usersExists).toBeUndefined(); + }); + + // New probe-table specs + + it('probe-table-not-present + stampGenesis=false runs first migration normally', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), 'CREATE TABLE users (id TEXT PRIMARY KEY);'); + const summary = applyMigrations(db, tmpDir, { stampGenesis: false }); + expect(summary.stamped).toEqual([]); + expect(summary.applied).toBe(1); + expect(summary.alreadyApplied).toBe(0); + const usersExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`) + .get(); + expect(usersExists).toBeDefined(); + const rows = readAppliedRows(db); + expect(rows).toHaveLength(1); + expect(rows[0].version).toBe('0001'); + }); + + it('genesisProbeTable=undefined defaults to users (authd back-compat)', () => { + writeFileSync(join(tmpDir, '0001_init.sql'), LEGACY_BOOTSTRAP_SQL); + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`); + + const warnings: string[] = []; + const summary = applyMigrations(db, tmpDir, { + logger: (msg) => warnings.push(msg), + }); + + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + expect(summary.alreadyApplied).toBe(1); + expect( + warnings.some((w) => w.includes('genesis-stamp') && w.includes("'users'")), + ).toBe(true); + }); + + it("genesisProbeTable='sessions' stamps a buchinese-shaped DB", () => { + writeFileSync( + join(tmpDir, '0001_init.sql'), + `CREATE TABLE users (id TEXT PRIMARY KEY); CREATE TABLE sessions (id TEXT PRIMARY KEY);`, + ); + db.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY);`); + + const warnings: string[] = []; + const summary = applyMigrations(db, tmpDir, { + genesisProbeTable: 'sessions', + logger: (msg) => warnings.push(msg), + }); + + expect(summary.stamped).toEqual(['0001']); + expect(summary.applied).toBe(0); + + const usersExists = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='users'`) + .get(); + expect(usersExists).toBeUndefined(); + + expect( + warnings.some((w) => w.includes('genesis-stamp') && w.includes("'sessions'")), + ).toBe(true); + }); +}); From 7d194a9cedd86949d8e7f8c0fc79e730c2144f2f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:37 -0700 Subject: [PATCH 4/9] chore: add package.json, tsconfig pair, and vitest config ESM-only, Node 20+, ships pre-compiled dist/. Peer dependency on better-sqlite3 >=11 <13. Dev pins: typescript ^5.5, vitest ^4.0 (resolved to 4.1.6), better-sqlite3 12.6.2 (matches authd). tsconfig.json (noEmit) is the dev/test config; tsconfig.build.json extends it and emits .js + .d.ts to dist/. Relative imports inside src/ use .js extensions so the emitted JS resolves correctly at runtime. --- package-lock.json | 1823 +++++++++++++++++++++++++++++++++++++++++++ package.json | 42 + tsconfig.build.json | 14 + tsconfig.json | 16 + vitest.config.ts | 5 + 5 files changed, 1900 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5425e49 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1823 @@ +{ + "name": "bchen-sqlite-migrate", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bchen-sqlite-migrate", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "better-sqlite3": "12.6.2", + "typescript": "^5.5", + "vitest": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "better-sqlite3": ">=11 <13" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aab80f4 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "bchen-sqlite-migrate", + "version": "0.1.0", + "description": "Lightweight SQLite migration runner for better-sqlite3 — numbered SQL files, sha256 checksums, idempotent re-apply, genesis-stamping.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./cli": { + "types": "./dist/cli.d.ts", + "import": "./dist/cli.js" + } + }, + "files": ["dist", "README.md", "LICENSE"], + "engines": { "node": ">=20" }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "better-sqlite3": ">=11 <13" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22", + "better-sqlite3": "12.6.2", + "typescript": "^5.5", + "vitest": "^4.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git" + }, + "keywords": ["sqlite", "migration", "better-sqlite3", "schema-migrations"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..fe022eb --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "outDir": "./dist", + "rootDir": "./src", + "allowImportingTsExtensions": false + }, + "include": ["src/**/*"], + "exclude": ["tests/**/*", "node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9042d98 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e0137ab --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { globals: false, environment: 'node' }, +}); From 0af987651cd210a0db88765463d2b9da887828c2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:41 -0700 Subject: [PATCH 5/9] chore: un-ignore /dist/ Appended '\!/dist/' override to the bottom of .gitignore so the committed build output is tracked. node_modules and other Nuxt-template-default entries remain ignored. Verified with 'git check-ignore -v dist/index.js' (no match, exit 1). --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2309cc8..22153b5 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ dist .yarn/install-state.gz .pnp.* + +# package: ship pre-compiled dist/ +!/dist/ From 7e29d8afbdb14ec570886de324bf643ae8a6dd5b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:45 -0700 Subject: [PATCH 6/9] ci: add Gitea Actions workflow Runs npm ci, typecheck, build, test, and a 'git diff --exit-code dist/' gate that catches 'forgot to re-run npm run build' before merge. Fires on PR / push to main / push to v* tags. --- .gitea/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..c4d55c3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,21 @@ +name: ci +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npm test + - name: ensure dist is up to date + run: git diff --exit-code dist/ From 5096ca174fb89b114574df06e0567315d88b22d3 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:50 -0700 Subject: [PATCH 7/9] docs: replace placeholder README with v0.1.0 docs Install via git+https with pinned tag, quick-start, API reference for the 4 runtime exports + the CLI sub-path, genesis-stamping explainer, the per-consumer probe-table table for the six fleet consumers, security note on the trusted-SQL boundary. --- README.md | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f3495b4..d4574f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,161 @@ -# sqlite-migrate +# bchen-sqlite-migrate -Tiny SQLite migration runner: version-numbered, checksum-verified, idempotent. Used across multiple projects. \ No newline at end of file +Tiny, forward-only SQLite migration runner for `better-sqlite3`: numbered `.sql` files, sha256 checksum verification, idempotent re-apply, transactional per-migration, and genesis-stamping for pre-existing prod DBs. + +Lifted from the [authd](https://gitea.bchen.dev/brendan/authd) auth service (PR #14, merge `ced21ab`) and generalized for cross-project use across the fleet. + +## Install + +Public repo — no auth needed: + +```bash +npm install git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0 +``` + +Pin to a tag (`#v0.1.0`), not a branch. + +Peer dependency: + +```json +"peerDependencies": { + "better-sqlite3": ">=11 <13" +} +``` + +Node 20+. ESM-only. Ships pre-compiled `dist/`, so no build step is needed in consumers. + +## Quick start + +```ts +import Database from 'better-sqlite3'; +import { applyMigrations } from 'bchen-sqlite-migrate'; + +const db = new Database('./app.db'); +const summary = applyMigrations(db, './migrations', { + genesisProbeTable: 'users', + logger: console.log, +}); +// summary: { applied, alreadyApplied, pending, stamped } +``` + +Migration files live in a single flat directory and follow the naming convention `NNNN_name.sql` (4+ digits, snake_case name, `.sql` extension), e.g. `0001_init.sql`, `0002_articles_user_scope.sql`. + +## API + +### `applyMigrations(db, migrationsDir, opts?) => MigrationSummary` + +Apply all pending migrations in version order. Each migration runs inside a transaction; failure rolls back. Re-running is idempotent — already-applied migrations are skipped after a checksum compare; mismatch throws. + +```ts +interface ApplyOptions { + /** Per-message logger (info + WARN). Default no-op. */ + logger?: (msg: string) => void; + /** Force-stamp the 0001 migration without executing it. */ + stampGenesis?: boolean; + /** Default 'users'. Probe table for "is this a pre-existing prod DB?". */ + genesisProbeTable?: string; +} + +interface MigrationSummary { + applied: number; // ran successfully this call + pending: number; // always 0; kept for forward-compat + alreadyApplied: number; // already in schema_migrations + stamped: string[]; // versions stamped without execution +} +``` + +### `listMigrations(migrationsDir) => MigrationFile[]` + +Reads + parses `NNNN_*.sql` files into `{ version, name, checksum, body }`. Throws on filenames that don't match `^(\d{4,})_([a-z0-9_]+)\.sql$`. + +### `readAppliedRows(db) => MigrationRow[]` + +Reads `schema_migrations`. Returns `[]` if the table doesn't exist. + +### `stampMigration(db, migrationsDir, version) => MigrationFile` + +Marks `version` as applied without running its body. Throws if already stamped. Useful for retrofitting an existing-prod DB to a new migration scheme. + +## Genesis stamping + +When applying against a DB that has tables but no `schema_migrations` row, the runner can mark the `0001` migration as "applied" without executing its body. This lets you adopt the migration system on an existing prod DB without re-running `CREATE TABLE` against tables that already exist. + +Two triggers: + +1. `applyMigrations(db, dir, { stampGenesis: true })` — explicit opt-in. Typically wired to an env var: + + ```bash + DB_MIGRATIONS_STAMP_GENESIS=1 node ./scripts/db-migrate.mjs + ``` + +2. `genesisProbeTable` — auto-detection. If this table exists in the DB and `schema_migrations` doesn't, stamp 0001. Default `'users'`. Pass your project's first-table name to match a non-authd shape. + +When stamping fires, the runner emits one WARN line via `logger`: + +``` +WARN genesis-stamp: marked 0001_init as applied without executing (detected pre-existing 'users' table) +``` + +Or, when triggered explicitly: + +``` +WARN genesis-stamp: marked 0001_init as applied without executing (stampGenesis option set) +``` + +## Per-consumer call signatures (fleet) + +| Consumer | `genesisProbeTable` | +|-------------|---------------------| +| authd | `'users'` (default — can omit) | +| buchinese | `'sessions'` | +| inventory | TBD — verify at adoption | +| nanodrop | `'users'` | +| dashcam | `'sessions'` | +| movement | TBD — verify at adoption | + +## CLI helper + +The package exports a wrapper at the `bchen-sqlite-migrate/cli` sub-path so each consumer writes a ~5-line shim: + +```ts +// scripts/db-migrate.ts +import { runMigrateCli } from 'bchen-sqlite-migrate/cli'; +import { openDb } from './_db-open.js'; + +process.exit(runMigrateCli({ + openDb, + migrationsDir: new URL('../db/migrations/', import.meta.url).pathname, + command: process.argv[2] as 'migrate' | 'status' | 'stamp', + version: process.argv[3], + stampGenesis: process.env.DB_MIGRATIONS_STAMP_GENESIS === '1', + genesisProbeTable: 'users', +})); +``` + +`runMigrateCli` **returns** an exit code rather than calling `process.exit` itself — the consumer is responsible for `process.exit(...)`. The wrapper closes the DB before returning. This keeps the helper testable (you can capture stdout/stderr via `PassThrough` streams). + +Three commands: + +- `migrate` — apply pending migrations. +- `status` — print each migration's state (`applied:` / `pending:` / `checksum-mismatch:`). Exit code `2` if any mismatched. +- `stamp ` — manually mark a version as applied without running its body. + +## Development + +```bash +git clone ssh://git@gitea.bchen.dev:2222/brendan/sqlite-migrate.git +cd sqlite-migrate +npm install +npm test +npm run build +``` + +`dist/` is committed (consumers install via `git+https://...#v0.1.0` and need the pre-built JS). CI verifies `dist/` is in sync with `src/` via `git diff --exit-code dist/`. Re-run `npm run build` before committing if you touched `src/`. + +## License + +MIT — see [LICENSE](./LICENSE). + +## Security + +This is a server-side library with no HTTP surface and no untrusted input. `db.exec(file.body)` runs each migration's SQL verbatim — **by design**, since migrations are trusted, committed-to-repo SQL. Do not point `migrationsDir` at a path that can be written to by untrusted users. From accd3ca3404cd93e7ac28499e8f60e6b0b1dcdd5 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:17:02 -0700 Subject: [PATCH 8/9] 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; +} From 93f5d63f26a19d45c06ff500cb1c686327800c3e Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:24:33 -0700 Subject: [PATCH 9/9] refactor: dedupe INSERT SQL and DDL exec in migrate.ts - Extract INSERT_MIGRATION_SQL constant; was repeated three times. - Hoist db.exec(SCHEMA_MIGRATIONS_DDL) above the genesis-stamp branch so it is no longer duplicated in both arms of the if/else. - Prepare insertRow once and reuse it for both genesis-stamping and the per-file insert in the apply loop. - Simplify existence probe in stampMigration to SELECT 1 and drop the misleading MigrationRow cast (only existence was checked). --- dist/migrate.js | 19 ++++++++----------- src/migrate.ts | 36 ++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/dist/migrate.js b/dist/migrate.js index 3aefa21..447bb34 100644 --- a/dist/migrate.js +++ b/dist/migrate.js @@ -11,6 +11,7 @@ const SCHEMA_MIGRATIONS_DDL = ` applied_at INTEGER NOT NULL ); `; +const INSERT_MIGRATION_SQL = `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`; function sha256Hex(body) { return createHash('sha256').update(body).digest('hex'); } @@ -54,28 +55,24 @@ export function applyMigrations(db, migrationsDir, opts = {}) { 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)); + db.exec(SCHEMA_MIGRATIONS_DDL); + const insertRow = db.prepare(INSERT_MIGRATION_SQL); + const stamped = []; 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()); + insertRow.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])); + const appliedByVersion = new Map(readAppliedRows(db).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) { @@ -103,11 +100,11 @@ export function stampMigration(db, migrationsDir, version) { } db.exec(SCHEMA_MIGRATIONS_DDL); const existing = db - .prepare(`SELECT version, name, checksum, applied_at FROM schema_migrations WHERE version = ?`) + .prepare(`SELECT 1 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()); + db.prepare(INSERT_MIGRATION_SQL).run(file.version, file.name, file.checksum, Date.now()); return file; } diff --git a/src/migrate.ts b/src/migrate.ts index 252a7ee..58df0c7 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -43,6 +43,8 @@ const SCHEMA_MIGRATIONS_DDL = ` ); `; +const INSERT_MIGRATION_SQL = `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`; + function sha256Hex(body: string): string { return createHash('sha256').update(body).digest('hex'); } @@ -97,38 +99,31 @@ export function applyMigrations( } const schemaMigrationsExists = tableExists(db, 'schema_migrations'); - const stamped: string[] = []; - const genesisFile = files.find((f) => f.version === GENESIS_VERSION); const shouldStampGenesis = !schemaMigrationsExists && genesisFile !== undefined && (opts.stampGenesis === true || tableExists(db, probeTable)); + db.exec(SCHEMA_MIGRATIONS_DDL); + const insertRow = db.prepare(INSERT_MIGRATION_SQL); + + const stamped: string[] = []; 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()); + insertRow.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])); + const appliedByVersion = new Map(readAppliedRows(db).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); @@ -166,15 +161,16 @@ export function stampMigration( } db.exec(SCHEMA_MIGRATIONS_DDL); const existing = db - .prepare( - `SELECT version, name, checksum, applied_at FROM schema_migrations WHERE version = ?`, - ) - .get(file.version) as MigrationRow | undefined; + .prepare(`SELECT 1 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()); + db.prepare(INSERT_MIGRATION_SQL).run( + file.version, + file.name, + file.checksum, + Date.now(), + ); return file; }