import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, copyFileSync, chmodSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { createHash } from 'node:crypto'; import { Store, schemaDigest } from '../src/store.mjs'; function legacy(t) { const root = mkdtempSync(join(tmpdir(), 'comment-migration-')); t.after(() => rmSync(root, { recursive: true, force: true })); const source = join(root, 'schema-current.sqlite'); const db = new DatabaseSync(source); const ddl = readFileSync(new URL('./fixtures/schema-3b.sql', import.meta.url), 'utf8'); assert.equal(createHash('sha256').update(ddl).digest('hex'), '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'); db.exec(ddl); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b'); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db)); db.prepare('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)') .run('preserved', '2026-10-15T00:00:00.000Z', 'demo', 'action.allowed', '{"fixture":true}'); db.close(); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'); copyFileSync(source, path); chmodSync(path, 0o600); return { root, source, path }; } test('explicit 3b copy migration preserves evidence and opens as 3g; normal startup never migrates', t => { const { root, source } = legacy(t); const original = readFileSync(source); assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0); const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value; assert.equal(new Date(epoch).toISOString(), epoch); store.close(); assert.deepEqual(readFileSync(source), original); const again = new Store(root, { migrateSchema: true }); assert.equal(again.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value, epoch); again.close(); new Store(root).close(); }); test('migration refuses active writers and drifted legacy schemas, with no partial upgrade', t => { const { root, path } = legacy(t); const db = new DatabaseSync(path); db.exec('DROP TRIGGER events_no_delete'); db.close(); assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/); const check = new DatabaseSync(path); assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3b'); assert.equal(check.prepare("SELECT count(*) n FROM sqlite_master WHERE name='task_comment_cursors'").get().n, 0); check.close(); const other = legacy(t); const current = new Store(other.root, { migrateSchema: true }); t.after(() => current.close()); assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/); }); test('named migration registry chains 3b through 3g to a fixture-only future version in order', async t => { const { root } = legacy(t); const { writeFileSync } = await import('node:fs'); const { pathToFileURL } = await import('node:url'); const moduleRoot = join(root, 'future-bus'); mkdirSync(join(moduleRoot, 'src'), { recursive: true }); mkdirSync(join(moduleRoot, 'src/migrations')); const futureSQL = 'CREATE TABLE future_fixture (value TEXT) STRICT;\n'; const currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); const futureDDL = currentDDL + futureSQL; const sha = createHash('sha256').update(futureDDL).digest('hex'); let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8'); source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`) .replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';") .replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); writeFileSync(join(moduleRoot, 'src/store.mjs'), source); writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL); for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql']) copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name)); const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs'))); const future = new FutureStore(root, { migrateSchema: true }); assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture'); assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0); assert.equal(future.get("SELECT id FROM events").id, 'preserved'); future.close(); new FutureStore(root).close(); }); test('migration refuses mismatched source metadata without changing schema or evidence', t => { const { root, path } = legacy(t); const db = new DatabaseSync(path); db.exec("DROP TRIGGER meta_no_update; UPDATE meta SET value='untrusted' WHERE key='schema_digest'; CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END"); db.close(); assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/); const check = new DatabaseSync(path); assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3b'); assert.equal(check.prepare('SELECT id FROM events').get().id, 'preserved'); check.close(); }); test('offline command migrates only the supplied fixture root and reports usage errors', async t => { const { spawnSync } = await import('node:child_process'); const { root } = legacy(t); const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname; const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); assert.equal(migrated.status, 0, migrated.stderr); assert.match(migrated.stdout, /bus schema 3v ready/); new Store(root).close(); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); assert.equal(usage.status, 2); assert.match(usage.stderr, /usage:/); });