The owner handles a new request from the Console: pick the mode (new autonomous, new guided or an existing project), answer the project manager's questions and confirm an attachment, with no copy-to-terminal step. Bus: 3p->3q append-only migration widens the request_events kinds. The PM asks one question with request.question. The owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console join the two console reads in CONSOLE_VERBS. An answer is an attributed human.input linked to its question and request, recorded once per op and never a PRD approval. An attach pick makes the PM propose that project as a confirmation decision; nothing is mapped on the pick. A mode change takes the expected revisions and leaves the bound plan as evidence. Cancel keeps a created project. Console: request-http.mjs serves GET /api/requests/:request, /projects and /draft and POST /answer, /attach, /mode and /cancel with the signed-in owner, same-origin and bounded-body checks of the decision routes. requests.js draws the panel under the owner's chat message, answers the mode decision through the existing confirmation endpoints, and after a lost answer response reads the request first and resends the same op only on the owner's click.
533 lines
19 KiB
JavaScript
533 lines
19 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);
|
|
// Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them.
|
|
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
|
|
// Row 124 adds usage_records and its three append-only triggers.
|
|
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
|
|
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
|
|
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
|
|
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
|
|
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
|
|
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
|
|
// Task 4 (3p to 3q) and Task 3 (3q to 3r) widen request_events.kind in place; all 30 tables and 114 guards remain.
|
|
assert.equal(count('table') - 1, 30);
|
|
assert.equal(count('trigger'), 114);
|
|
assert.equal(count('view'), 5);
|
|
});
|