import test from 'node:test'; import assert from 'node:assert/strict'; // Slice 1 prototype, v3a schema (v3 plus the task event rules of lead decision 56, Q3). Same pattern as proto-v3.mjs. import { DatabaseSync } from 'node:sqlite'; import { readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; const root = mkdtempSync(join(tmpdir(), 'bus-schema-')); const f = join(root, 'bus.sqlite'); test('v3b prototype refusals, views and append-only mutations', (t) => { t.after(() => { db.close(); rmSync(root, { recursive: true, force: true }); }); let db = new DatabaseSync(f, { timeout: 5000 }); db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')); let tick = 0; const now = () => new Date(Date.UTC(2026, 9, 4, 12, 0, tick++)).toISOString(); const refused = new Set([ 'raise without blocking', 'raise with blocking 2', 'raise blocking without task_ref', 'unknown kind task.deleted', 'unknown kind task.deleted with a subject', 'credential.changed without instance', 'credential.expiring service github', 'task.missing without reason', 'task.missing reason deleted', 'task.missing without subject', 'task.missing moved without project', 'task.state without subject', 'task.state subject PROJ-41', 'task.state subject vikunja:3/41x', 'task.state subject vikunja:03/41', 'task.state subject vikunja:3/4/1', 'task.created without request', 'task.created request names an unknown event', 'task.created request names a credential event', 'task.created request from another business', 'task.created request as a number', 'task.created without requirement', 'task.created requirement REQ-task-1', 'task.created requirement REQ-TASK-0', 'task.created without subject', 'decision with task_ref vikunja:3/41x', 'launch.revoked by pm run', 'self without role and run', 'poll with a role', 'poll without via', 'poll without read_at', 'self with via', 'unknown via webhook', 'fields without bucket', 'bucket as text', 'gone on a board read', 'gone on self', 'bad task_ref', 'task_ref vikunja:3/41x', 'bad digest', ]); const tested = new Set(); const tryit = (label, fn) => { tested.add(label); if (refused.has(label) || / (UPDATE|DELETE|INSERT OR REPLACE)$/.test(label)) assert.throws(fn, undefined, label); else assert.doesNotThrow(fn, label); }; const expected = { urgent_inbox: ['d-3'], 'urgent_inbox after resolve': [], 'external after cursor X': [], 'external after cursor Y': ['vikunja:3/41'], 'external after self Z, stale board read': [], 'external after stale cursor read': [], 'external after board agrees': [], "external after person's move": ['vikunja:3/41'], tasks_open: ['vikunja:3/41', 'vikunja:3/42'], 'tasks_open after tombstone': ['vikunja:3/41'], }; const show = (label, sql) => { const rows = db.prepare(sql).all(); if (Object.hasOwn(expected, label)) assert.deepEqual( rows.map((r) => r.id ?? r.task_ref), expected[label], label, ); }; const hex = (s) => createHash('sha256').update(s).digest('hex'); const schemaDigest = (d) => hex( d .prepare('SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name') .all() .map((r) => `${r.type}|${r.name}|${r.sql}`) .join('\n'), ); db.prepare("INSERT INTO meta (key, value) VALUES ('schema_digest', ?)").run(schemaDigest(db)); const check = () => db.prepare("SELECT value FROM meta WHERE key = 'schema_digest'").get().value === schemaDigest(db) ? 'match' : 'MISMATCH'; assert.equal(check(), 'match'); const dec = db.prepare( 'INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,blocking) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)', ); const opts = JSON.stringify([ { key: 'A', text: 'rotate' }, { key: 'B', text: 'wait' }, ]); tryit('raise without blocking', () => db.exec( `INSERT INTO decisions (id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation) VALUES ('d-0','${now()}','mosaic-stack','coder','run-C','gated','credential.mint','human','?','${opts}','A')`, ), ); tryit('raise with blocking 2', () => dec.run( 'd-1', now(), 'mosaic-stack', 'coder', 'run-C', 'gated', 'credential.mint', 'human', 'Rotate?', opts, 'A', 'vikunja:3/41', 2, ), ); tryit('raise blocking without task_ref', () => dec.run( 'd-2', now(), 'mosaic-stack', 'coder', 'run-C', 'gated', 'credential.mint', 'human', 'Rotate?', opts, 'A', null, 1, ), ); tryit('raise blocking gated with task_ref', () => dec.run( 'd-3', now(), 'mosaic-stack', 'coder', 'run-C', 'gated', 'credential.mint', 'human', 'Rotate coder vikunja token?', opts, 'A', 'vikunja:3/41', 1, ), ); tryit('raise non-blocking gated', () => dec.run( 'd-4', now(), 'mosaic-stack', 'pm', 'run-P', 'gated', 'deploy', 'human', 'Deploy?', opts, 'B', null, 0, ), ); tryit('raise blocking cross-role', () => dec.run( 'd-5', now(), 'mosaic-stack', 'coder', 'run-C', 'cross-role', 'task.scope.change', 'pm', 'Widen scope?', opts, 'B', 'vikunja:3/41', 1, ), ); show('urgent_inbox', 'SELECT id, task_ref FROM urgent_inbox'); tryit('resolve d-3 with A', () => db .prepare('INSERT INTO decision_events (decision,at,op,by,choice,via) VALUES (?,?,?,?,?,?)') .run('d-3', now(), 'resolved', 'jason', 'A', 'cli'), ); show('urgent_inbox after resolve', 'SELECT id FROM urgent_inbox'); const ev = db.prepare( 'INSERT INTO events (id,at,business,kind,actor_role,actor_run,subject,body) VALUES (?,?,?,?,?,?,?,?)', ); let n = 0; const e = (kind, role, run, body, subject = null) => ev.run(`e-${++n}`, now(), 'mosaic-stack', kind, role, run, subject, JSON.stringify(body)); tryit('unknown kind task.deleted', () => e('task.deleted', 'pm', 'run-P', {})); tryit('unknown kind task.deleted with a subject', () => e('task.deleted', 'pm', 'run-P', {}, 'vikunja:3/41'), ); tryit('credential.expiring vikunja coder', () => e('credential.expiring', null, null, { service: 'vikunja', instance: 'coder', expires: '2026-10-11' }), ); tryit('credential.expired vikunja coder', () => e('credential.expired', null, null, { service: 'vikunja', instance: 'coder', decision: 'd-3' }), ); tryit('credential.changed gitea pm', () => e('credential.changed', null, null, { service: 'gitea', instance: 'pm', stat: { inode: 1, size: 41 } }), ); tryit('credential.changed without instance', () => e('credential.changed', null, null, { service: 'gitea' }), ); tryit('credential.expiring service github', () => e('credential.expiring', null, null, { service: 'github', instance: 'pm' }), ); tryit('task.missing without reason', () => e('task.missing', null, null, { reconcile: 'r-1' }, 'vikunja:3/40'), ); tryit('task.missing reason deleted', () => e('task.missing', null, null, { reason: 'deleted' }, 'vikunja:3/40'), ); tryit('task.missing without subject', () => e('task.missing', null, null, { reason: 'not-found' })); tryit('task.missing moved without project', () => e('task.missing', null, null, { reason: 'moved' }, 'vikunja:3/40'), ); tryit('task.missing not-found', () => e('task.missing', null, null, { reason: 'not-found' }, 'vikunja:3/40'), ); tryit('task.missing moved to project 9', () => e('task.missing', null, null, { reason: 'moved', project: 9 }, 'vikunja:3/43'), ); tryit('task.state without subject', () => e('task.state', 'coder', 'run-C', { bucket: 12 })); tryit('task.state subject PROJ-41', () => e('task.state', 'coder', 'run-C', { bucket: 12 }, 'PROJ-41')); tryit('task.state subject vikunja:3/41x', () => e('task.state', 'coder', 'run-C', { bucket: 12 }, 'vikunja:3/41x'), ); tryit('task.state subject vikunja:03/41', () => e('task.state', 'coder', 'run-C', { bucket: 12 }, 'vikunja:03/41'), ); tryit('task.state subject vikunja:3/4/1', () => e('task.state', 'coder', 'run-C', { bucket: 12 }, 'vikunja:3/4/1'), ); tryit('task.state subject vikunja:3/41', () => e('task.state', 'coder', 'run-C', { bucket: 12 }, 'vikunja:3/41'), ); tryit('human.input from the cli', () => ev.run( 'h-1', now(), 'mosaic-stack', 'human.input', null, null, null, JSON.stringify({ via: 'cli', text: 'Add the broker push.' }), ), ); tryit('human.input in another business', () => ev.run('h-2', now(), 'other', 'human.input', null, null, null, JSON.stringify({ via: 'cli', text: 'x' })), ); const tc = (body, subject = 'vikunja:3/50') => e('task.created', 'pm', 'run-P', body, subject); tryit('task.created without request', () => tc({ requirement: 'REQ-TASK-1' })); tryit('task.created request names an unknown event', () => tc({ request: 'h-9', requirement: 'REQ-TASK-1' }), ); tryit('task.created request names a credential event', () => tc({ request: 'e-3', requirement: 'REQ-TASK-1' }), ); tryit('task.created request from another business', () => tc({ request: 'h-2', requirement: 'REQ-TASK-1' }), ); tryit('task.created request as a number', () => tc({ request: 1, requirement: 'REQ-TASK-1' })); tryit('task.created without requirement', () => tc({ request: 'h-1' })); tryit('task.created requirement REQ-task-1', () => tc({ request: 'h-1', requirement: 'REQ-task-1' })); tryit('task.created requirement REQ-TASK-0', () => tc({ request: 'h-1', requirement: 'REQ-TASK-0' })); tryit('task.created without subject', () => tc({ request: 'h-1', requirement: 'REQ-TASK-1' }, null)); tryit('task.created cites h-1 and REQ-TASK-1', () => tc({ request: 'h-1', requirement: 'REQ-TASK-1' })); tryit('decision with task_ref vikunja:3/41x', () => dec.run( 'd-6', now(), 'mosaic-stack', 'coder', 'run-C', 'gated', 'deploy', 'human', '?', opts, 'A', 'vikunja:3/41x', 0, ), ); show('e-3 is', "SELECT kind FROM events WHERE id = 'e-3'"); show( 'trail from h-1', "SELECT e.kind, e.subject FROM events e WHERE json_extract(e.body, '$.request') = 'h-1'", ); tryit('digest.sent', () => e('digest.sent', null, null, { decisions: ['d-4'], transport: 'discord-dm' })); tryit('launch.revoked by pm run', () => e('launch.revoked', 'pm', 'run-P', {})); tryit('launch.revoked by human', () => e('launch.revoked', null, null, { via: 'cli' })); show('launch_state', 'SELECT business, state FROM launch_state'); tryit('launch.restored by human', () => e('launch.restored', null, null, { via: 'cli' })); show('launch_state', 'SELECT business, state FROM launch_state'); const snap = db.prepare( 'INSERT INTO task_snapshots (at,business,task_ref,updated,etag,digest,fields,source,via,read_at,role,run) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', ); const at = (sec) => new Date(Date.UTC(2026, 9, 4, 13, 0, sec)).toISOString(); const self = (ref, updated, fields, sec, role, run) => snap.run( at(sec), 'mosaic-stack', ref, updated, `"${hex(JSON.stringify(fields)).slice(0, 8)}"`, hex(JSON.stringify(fields)), JSON.stringify(fields), 'self', null, null, role, run, ); const poll = (ref, updated, fields, via, readSec) => snap.run( at(readSec + 1), 'mosaic-stack', ref, updated, null, hex(JSON.stringify(fields)), JSON.stringify(fields), 'poll', via, at(readSec), null, null, ); const raw = (source, via, readAt, role, run, fields) => snap.run( at(59), 'mosaic-stack', 'vikunja:3/49', '2026-10-04T12:00:00Z', null, hex(JSON.stringify(fields)), JSON.stringify(fields), source, via, readAt, role, run, ); // Buckets: 11 todo, 12 in-progress, 13 in-review, 14 blocked, 15 done. const X = { title: 'Add broker push', bucket: 12, done: 0 }, Y = { ...X, title: 'Add broker push (Jason edit)' }, Z = { ...Y, bucket: 13 }, B = { ...Z, bucket: 14 }, W = { title: 'Add broker push', bucket: 11, done: 0 }; const U = '2026-10-04T12:00:00Z'; tryit('self without role and run', () => raw('self', null, null, null, null, X)); tryit('poll with a role', () => raw('poll', 'board', at(58), 'pm', 'run-P', X)); tryit('poll without via', () => raw('poll', null, at(58), null, null, X)); tryit('poll without read_at', () => raw('poll', 'board', null, null, null, X)); tryit('self with via', () => raw('self', 'board', at(58), 'coder', 'run-C', X)); tryit('unknown via webhook', () => raw('poll', 'webhook', at(58), null, null, X)); tryit('fields without bucket', () => raw('poll', 'cursor', at(58), null, null, { title: 'x', done: 0 })); tryit('bucket as text', () => raw('poll', 'cursor', at(58), null, null, { title: 'x', bucket: 'in-progress', done: 0 }), ); tryit('gone on a board read', () => raw('poll', 'board', at(58), null, null, { gone: 'not-found' })); tryit('gone on self', () => raw('self', null, null, 'pm', 'run-P', { gone: 'not-found' })); tryit('bad task_ref', () => snap.run( at(59), 'mosaic-stack', 'PROJ-41', U, null, hex('x'), JSON.stringify(X), 'poll', 'board', at(58), null, null, ), ); tryit('task_ref vikunja:3/41x', () => snap.run( at(59), 'mosaic-stack', 'vikunja:3/41x', U, null, hex('x'), JSON.stringify(X), 'poll', 'board', at(58), null, null, ), ); tryit('bad digest', () => snap.run( at(59), 'mosaic-stack', 'vikunja:3/41', U, null, 'abc', JSON.stringify(X), 'poll', 'board', at(58), null, null, ), ); tryit('self X by coder, response :02', () => self('vikunja:3/41', U, X, 2, 'coder', 'run-C')); tryit('cursor X sent :04 (unchanged)', () => poll('vikunja:3/41', U, X, 'cursor', 4)); show('external after cursor X', 'SELECT task_ref FROM task_external_changes'); tryit('cursor Y sent :06, same second (person edit)', () => poll('vikunja:3/41', U, Y, 'cursor', 6)); show('external after cursor Y', 'SELECT task_ref, via FROM task_external_changes'); tryit('self Z by coder (move to in-review), response :10', () => self('vikunja:3/41', U, Z, 10, 'coder', 'run-C'), ); tryit('stale board read sent :09 shows Y', () => poll('vikunja:3/41', U, Y, 'board', 9)); show('external after self Z, stale board read', 'SELECT task_ref FROM task_external_changes'); tryit('stale cursor read, updated 11:59:00', () => poll('vikunja:3/41', '2026-10-04T11:59:00Z', W, 'cursor', 20), ); show('external after stale cursor read', 'SELECT task_ref FROM task_external_changes'); tryit('board read sent :30 agrees with Z', () => poll('vikunja:3/41', U, Z, 'board', 30)); show('external after board agrees', 'SELECT task_ref FROM task_external_changes'); tryit('person moves to blocked, updated unchanged, board sent :40', () => poll('vikunja:3/41', U, B, 'board', 40), ); show("external after person's move", 'SELECT task_ref, via FROM task_external_changes'); tryit('board read on vikunja:3/42 with no self row', () => poll('vikunja:3/42', '2026-10-04T12:01:00Z', W, 'board', 41), ); tryit('cursor read on vikunja:3/44, done', () => poll('vikunja:3/44', '2026-10-04T12:01:00Z', { ...W, bucket: 15, done: 1 }, 'cursor', 41), ); show('tasks_open', 'SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref'); tryit('tombstone for vikunja:3/42 (GET 404)', () => poll('vikunja:3/42', '2026-10-04T12:01:00Z', { gone: 'not-found' }, 'task', 45), ); show('tasks_open after tombstone', 'SELECT task_ref, bucket FROM tasks_open ORDER BY task_ref'); show('external, all', 'SELECT task_ref, via FROM task_external_changes ORDER BY task_ref'); const keys = { meta: "key = 'schema_digest'", events: "id = 'h-1'", role_claims: '1', decisions: "id = 'd-3'", decision_events: '1', messages: '1', deliveries: '1', task_snapshots: 'seq = 1', }; db.exec( "INSERT INTO role_claims (at,business,role,op,holder_run,harness,by) VALUES ('x','mosaic-stack','pm','claim','run-P','pi','run-P')", ); db.exec( "INSERT INTO messages (id,at,business,from_role,from_run,to_role,class,decision,body) VALUES ('m-1','x','mosaic-stack','pm','run-P','human','RESULT','d-3','rotate')", ); db.exec("INSERT INTO deliveries (message,at,op,transport) VALUES ('m-1','x','delivered','discord-dm')"); for (const [tbl, where] of Object.entries(keys)) { const row = db.prepare(`SELECT * FROM ${tbl} WHERE ${where} LIMIT 1`).get(); const cols = Object.keys(row); const col = cols.find((c) => !['seq', 'id', 'key'].includes(c)); tryit(`${tbl} UPDATE`, () => db.exec(`UPDATE ${tbl} SET ${col} = ${col} WHERE ${where}`)); tryit(`${tbl} DELETE`, () => db.exec(`DELETE FROM ${tbl} WHERE ${where}`)); tryit(`${tbl} INSERT OR REPLACE`, () => db .prepare( `INSERT OR REPLACE INTO ${tbl} (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`, ) .run(...cols.map((c) => row[c])), ); } db.close(); db = new DatabaseSync(f, { timeout: 5000 }); assert.equal(check(), 'match'); db.exec('DROP TRIGGER task_snapshots_no_update'); db.close(); db = new DatabaseSync(f, { timeout: 5000 }); assert.equal(check(), 'MISMATCH'); const count = (type) => db.prepare('SELECT count(*) n FROM sqlite_master WHERE type = ?').get(type).n; for (const label of refused) assert.ok(tested.has(label), label); assert.equal(count('table') - 1, 8); assert.equal(count('trigger'), 35); assert.equal(count('view'), 5); });