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]>
523 lines
18 KiB
JavaScript
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);
|
|
});
|