import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createHash } from 'node:crypto'; import { DatabaseSync } from 'node:sqlite'; import { Store, schemaDigest } from '../src/store.mjs'; // Row 236 (#1742, #436b r5 section 7): the 3j→3k step adds the gitea_users and gitea_grants tables and // nothing else. It runs on a 3j store built here from the pinned 3j schema (02da6a2ae's schema.sql) and // fixture rows. No live store is read or copied. const AT = '2026-10-21T00:00:00.000Z'; const ddl3j = readFileSync(new URL('./fixtures/schema-3j.sql', import.meta.url), 'utf8'); const here = (p) => new URL(p, import.meta.url); const NEW = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1']; function source3j(t) { const root = mkdtempSync(join(tmpdir(), 'gitea-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path); assert.equal(createHash('sha256').update(ddl3j).digest('hex'), '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c'); db.exec(ddl3j); const run = (sql, ...args) => db.prepare(sql).run(...args); run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3j'); run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db)); run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT); run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT); run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', null, 'grant', '{"operation":"grant.host-enroll","decision":"d1"}'); run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}'); db.close(); chmodSync(path, 0o600); return { root, path }; } // Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects. const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } } // Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives // each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step // leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other // new row still fails. function migrated3l(now) { const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name)); assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]); return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) }; } function master(path) { const db = new DatabaseSync(path, { readOnly: true }); try { return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r })); } finally { db.close(); } } const version = (path) => { const db = new DatabaseSync(path, { readOnly: true }); try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); } }; // A copy of the bus module whose store.mjs, schema.sql and 3j→3k step the caller edits. async function engine(root, name, { store, schema, step = (s) => s }) { const moduleRoot = join(root, name); mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true }); const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source; if (store) assert.notEqual(edited, source); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8')); for (const file 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']) copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file)); writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8'))); copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql')); copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql')); copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql')); copyFileSync(here('../src/migrations/3n-3o.sql'), join(moduleRoot, 'src/migrations/3n-3o.sql')); copyFileSync(here('../src/migrations/3o-3p.sql'), join(moduleRoot, 'src/migrations/3o-3p.sql')); copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { const root = mkdtempSync(join(tmpdir(), 'gitea-store-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT); return store; } const user = (store, values = {}) => store.run( 'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(:business,:agent,:user,:id,:nonce,:decision)', { business: 'demo', agent: 'coder', user: 'demo-coder', id: 7, nonce: '0123456789abcdef', decision: 'd1', ...values }); const grant = (store, values = {}) => store.run( 'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(:business,:agent,:user,:token,:name,:projects,:rotate,:decision)', { business: 'demo', agent: 'coder', user: 'demo-coder', token: 9, name: 'mosaic-demo-coder-gitea-d1', projects: '["app"]', rotate: '2026-12-01', decision: 'd1', ...values }); test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the two tables and their keys', t => { const { root, path } = source3j(t), before = rows(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); assert.equal(version(path), '3v'); assert.deepEqual(migrated3l(rows(path)), before); const now = master(path); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort()); const store = new Store(root); try { assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok'); assert.deepEqual(store.all('PRAGMA foreign_key_check'), []); assert.equal(store.get('SELECT count(*) AS n FROM gitea_users').n, 0); assert.equal(store.get('SELECT count(*) AS n FROM gitea_grants').n, 0); user(store); grant(store); } finally { store.close(); } }); test('a 3j→3k step that fails partway leaves the store at 3j with every row and the schema intact', async t => { const { root, path } = source3j(t), before = rows(path), was = master(path); // The real step plus a statement that fails only when events has a row, so the empty replay at // import passes and the store's run fails after both tables exist. const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' }); assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/); assert.equal(version(path), '3j'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), before); new Store(root, { migrateSchema: true }).close(); assert.equal(version(path), '3v'); }); test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { const { root, path } = source3j(t); new Store(root, { migrateSchema: true }).close(); const after = rows(path), was = master(path); const Store3j = await engine(root, 'engine-3j', { schema: ddl3j, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`) .replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';") .replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step'); assert.throws(() => new Store3j(root), /schema-mismatch/); assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/); assert.equal(version(path), '3v'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); test('gitea_users: one user per business and agent, an id above 0, a 16-hex nonce and a real decision', t => { const store = fresh(t); user(store); assert.throws(() => user(store, { user: 'other' }), /UNIQUE constraint failed: gitea_users\.business, gitea_users\.agent/); user(store, { business: 'other' }); for (const nonce of ['0123456789abcde', '0123456789abcdef0', '0123456789ABCDEF', '0123456789abcdeg']) assert.throws(() => user(store, { agent: 'x', nonce }), /CHECK constraint failed/, nonce); assert.throws(() => user(store, { agent: 'x', id: 0 }), /CHECK constraint failed/); assert.throws(() => user(store, { agent: 'x', id: 'seven' }), /cannot store TEXT value in INTEGER column/); assert.throws(() => user(store, { agent: 'x', decision: 'nope' }), /FOREIGN KEY constraint failed/); }); test('gitea_grants: one grant per agent, a projects array, a token id above 0, and only for a recorded user', t => { const store = fresh(t); assert.throws(() => grant(store), /FOREIGN KEY constraint failed/); user(store); for (const projects of ['[]', '{}', '"app"', 'not json']) assert.throws(() => grant(store, { projects }), /CHECK constraint failed/, projects); assert.throws(() => grant(store, { token: 0 }), /CHECK constraint failed/); assert.throws(() => grant(store, { decision: 'nope' }), /FOREIGN KEY constraint failed/); grant(store); assert.throws(() => grant(store, { token: 10 }), /UNIQUE constraint failed/); assert.throws(() => store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'"), /FOREIGN KEY constraint failed/); store.run("DELETE FROM gitea_grants WHERE business='demo' AND agent='coder'"); store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'"); });