Files
stack/packages/bus/tests/schema.test.mjs
T
jason.woltjeandClaude Opus 5.5 38828a2cb3 feat(bus): the bus and the broker core (row 37, S2, rocko)
Rocko's round 2 candidate, approved by Darkwing (#1519 comment 26757).
build.patch 40d7e838, manifest 61519059, 24 files under packages/bus,
schema v3b (179ffe35, lead decision 60). Integration gate in a git
worktree of 942dca9e (S1 in the tree) plus the patch: bus 43/43 and
business 60/60 on Node 24 and 26, every package test and every
scripts/test-*.sh green, test-task 98/98 with the live-provider cases.
Rulings from lead decisions 62 and 63: the human proof is cooperative in
slice 1, and a self-raised cross-role decision routes to the human.
Single-use gated approvals follow in row 43.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 17:15:53 -05:00

523 lines
18 KiB
JavaScript

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