bus: comment cursors survive a restart, schema 3b to 3c (row 49, #1535)

The broker stores the highest comment id seen per business and task in an
append-only table, task_comment_cursors. The tasks adapter restores those
ids before its startup reconcile, and the first reconcile reads each
task's comment list even with no cached count, so a comment posted while
the bus was down is found. Cursor rows, snapshots and comment events
commit in one transaction. migrate-schema.mjs takes a stopped store from
3b to 3c offline; the store refuses a 3b database until it runs.
scripts/land-work/read.mjs reads SCHEMA_VERSION from store.mjs.

The live bus is 3b. From this commit on, land-work refuses it with
bus-schema until Mos migrates in the schema window
(agents/sage/work/window-plan/schema-chain-3c-3e.md).

Candidate 87ef1321a1a08be64a20499889fc24b5549b47d6d33abfd86db95f62fb3cb6d0,
Rocko packet agents/rocko/work/queue-49/r1 (ea5e70fa8), base 7b166a0f1.
Filbert approved in 28579 and Darkwing in 28587. Landing gate at
ebbbf62da: 24 suites green, 0 failed. 14 package suites passed 1598
tests (bus 178, tasks 97, business 92, cli 195, webui 112). The 10
script suites passed (land-work 84, queue 156). Follow-ups: P19 tasks 300
(duplicate cursor) and 304 (push a task's cursor only when consider runs
for it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 12:43:56 -05:00
co-authored by Claude Opus 5.5
parent 06e9640261
commit 3a4afdc03a
17 changed files with 933 additions and 52 deletions
+39 -7
View File
@@ -447,15 +447,47 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is unchanged v3b, SHA-256
`179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65`.
It has eight tables, **36** triggers and five views. The prototype's displayed
35 is after deliberately dropping a guard. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3b`, WAL,
`schema.sql` is v3c, SHA-256
`7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb`.
It has nine tables, 40 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3c`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on every table.
No migration or pruning command ships here.
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
`task_comment_cursors` holds append-only `(business, task_id, last_comment)` rows.
The latest sequence wins, and a database guard requires the ID to increase.
Trusted sync commits a new cursor and its comment event in one transaction;
agent snapshots and the public task digest/projection are unchanged. An empty
poll writes no cursor. Growth is at most one row per observed new comment, and
usually one per batch. IDs include bots, but bot comments never contribute to
the external-comment count. Task IDs survive project moves within a business.
Normal startup refuses a 3b database. The explicit offline command is:
```sh
node packages/bus/src/migrate-schema.mjs /absolute/dataRoot
```
Mos runs it only with the bus stopped, in an approved downtime/re-pin window,
after a consistent backup. Development seats run it only against disposable
fixtures. It takes the same exclusive writer lock, checks the pinned source
schema and metadata, WAL, timestamps and integrity, then runs named migration
steps in order. Each step atomically adds its DDL, changes only schema version
and digest metadata, restores the metadata update guard, and verifies its target
digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. Prior
steps stay pinned under `src/migrations/`; later versions append a step. A completed
step remains valid if a later step refuses; rerunning resumes from that version.
Already-current migration is a validated no-op. Missing stores refuse.
Before a task has a cursor, comments at or after the epoch minus 60 seconds are
eligible (rounded down to tracker seconds). This deliberately favors a possible
cutover duplicate over dropping comments with up to one minute of tracker clock
lag. Older historical comments are baselined without events; a tracker clock
more than a minute behind may still lose initial comments, and a clock ahead can
replay already reported comments once at migration. Once an ID is stored, only
IDs govern freshness, including after a long outage. No other event semantics,
tracker client, or pruning command changes here.
Run `node --test packages/bus/tests/*.test.mjs`. Tests use private disposable
directories, fixture token files, fake process tables, real local child brokers,
+18
View File
@@ -209,3 +209,21 @@ CREATE VIEW urgent_inbox AS
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
+32 -12
View File
@@ -599,23 +599,35 @@ export class Broker {
}
// Trusted S3 adapter only. With a cap it records a role's own completed write: the external call
// already happened, so this records and does not re-authorize. Without one it records sync reads.
recordTask({ cap = null, business, snapshots = [], events = [] }) {
recordTask({ cap = null, business, snapshots = [], events = [], commentCursors = [] }) {
const s = cap === null ? { business, role: null, run: null } : this.#session(cap);
this.#business(business);
if (cap !== null && (s.human || s.reader || s.business !== business)) fail('agent-required');
if (!Array.isArray(snapshots) || !Array.isArray(events)) fail('invalid-request');
if (!Array.isArray(snapshots) || !Array.isArray(events) || !Array.isArray(commentCursors)) fail('invalid-request');
if (cap !== null && commentCursors.length) fail('agent-required');
const kinds = cap === null ? POLL_KINDS : SELF_KINDS;
this.#secretCheck({ snapshots, events });
return this.#store.transaction(() => ({
snapshots: snapshots.map((x) => this.#snapshot(s, x)),
events: events.map((e) => {
keys(e, ['kind', 'body', 'subject'], ['kind', 'body']);
if (!kinds.has(e.kind)) fail('reserved-event');
if (!object(e.body)) fail('invalid-request');
return this.#event(s, e.kind, e.body, e.subject ?? null);
}),
}));
this.#secretCheck({ snapshots, events, commentCursors });
return this.#store.transaction(() => {
for (const x of commentCursors) {
keys(x, ['task_id', 'last_comment'], ['task_id', 'last_comment']);
if (![x.task_id, x.last_comment].every((n) => Number.isSafeInteger(n) && n > 0)) fail('invalid-request');
this.#store.run(
'INSERT INTO task_comment_cursors(business,task_id,last_comment) VALUES(?,?,?)',
business, x.task_id, x.last_comment,
);
}
return {
snapshots: snapshots.map((x) => this.#snapshot(s, x)),
events: events.map((e) => {
keys(e, ['kind', 'body', 'subject'], ['kind', 'body']);
if (!kinds.has(e.kind)) fail('reserved-event');
if (!object(e.body)) fail('invalid-request');
return this.#event(s, e.kind, e.body, e.subject ?? null);
}),
};
});
}
#snapshot(s, x) {
const poll = s.role === null;
keys(
@@ -649,6 +661,14 @@ export class Broker {
// priority and requirement (#taskRows).
taskView(business, view, arg = null) {
this.#business(business);
if (view === 'comments' && arg === null) return {
epoch: this.#store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value,
cursors: this.#store.all(
`SELECT task_id,last_comment FROM task_comment_cursors c WHERE business=? AND seq=(
SELECT max(seq) FROM task_comment_cursors WHERE business=c.business AND task_id=c.task_id
) ORDER BY task_id`, business,
),
};
const parse = (r) => ({ ...r, fields: JSON.parse(r.fields) });
if (view === 'current' && arg === null)
return this.#store.all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business).map(parse);
+15
View File
@@ -0,0 +1,15 @@
import { Store, SCHEMA_VERSION } from './store.mjs';
// Offline, explicitly invoked in a human-approved downtime window. No config or secrets read.
if (process.argv.length !== 3) {
process.stderr.write('usage: node packages/bus/src/migrate-schema.mjs /absolute/dataRoot\n');
process.exitCode = 2;
} else {
try {
const store = new Store(process.argv[2], { migrateSchema: true });
store.close();
process.stdout.write(`bus schema ${SCHEMA_VERSION} ready\n`);
} catch (e) {
process.stderr.write(`migration refused: ${e.message}\n`);
process.exitCode = 1;
}
}
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
+211
View File
@@ -0,0 +1,211 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
+61 -4
View File
@@ -13,7 +13,19 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
export const SCHEMA_SHA256 = '7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb';
export const SCHEMA_VERSION = '3c';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
const migrations = [{
from: '3b',
to: '3c',
sql: readFileSync(new URL('./migrations/3b-3c.sql', import.meta.url), 'utf8'),
initialize: (store) => store.run(
'INSERT INTO meta(key,value) VALUES (?,?)', 'comment_cursor_epoch', new Date().toISOString(),
),
}];
const timedTables = [
'events',
'role_claims',
@@ -37,6 +49,19 @@ const reference = new DatabaseSync(':memory:');
reference.exec(ddl);
const EXPECTED_DIGEST = schemaDigest(reference);
reference.close();
const legacy = new DatabaseSync(':memory:');
if (hex(legacyDDL) !== LEGACY_SHA256) throw Error('legacy-schema-source-mismatch');
legacy.exec(legacyDDL);
for (let i = 0; i < migrations.length; i++) {
const step = migrations[i];
if (i && migrations[i - 1].to !== step.from) throw Error('migration-chain-mismatch');
step.fromDigest = schemaDigest(legacy);
legacy.exec(step.sql);
step.toDigest = schemaDigest(legacy);
}
if (migrations.at(-1).to !== SCHEMA_VERSION || schemaDigest(legacy) !== EXPECTED_DIGEST)
throw Error('migration-target-mismatch');
legacy.close();
function exists(path) {
try {
return lstatSync(path);
@@ -64,7 +89,7 @@ export class Store {
#lockStat;
#closed = false;
#inTransaction = false;
constructor(dataRoot) {
constructor(dataRoot, { migrateSchema = false } = {}) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot)) throw Error('unsafe-path');
const rootStat = lstatSync(dataRoot);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || rootStat.uid !== process.getuid())
@@ -94,6 +119,7 @@ export class Store {
}
this.path = join(this.directory, 'bus.sqlite');
const fresh = !exists(this.path);
if (fresh && migrateSchema) throw Error('migration-source-missing');
if (fresh) closeSync(openSync(this.path, 'wx', 0o600));
else safe(this.path);
for (const suffix of ['-wal', '-shm']) if (exists(this.path + suffix)) safe(this.path + suffix);
@@ -104,13 +130,15 @@ export class Store {
this.#db.exec('PRAGMA journal_mode=WAL');
this.transaction(() => {
this.#db.exec(ddl.replace('PRAGMA journal_mode = WAL;', ''));
this.run('INSERT INTO meta(key,value) VALUES (?,?)', 'schema_version', '3b');
this.run('INSERT INTO meta(key,value) VALUES (?,?)', 'schema_version', SCHEMA_VERSION);
this.run('INSERT INTO meta(key,value) VALUES (?,?)', 'schema_digest', EXPECTED_DIGEST);
this.run('INSERT INTO meta(key,value) VALUES (?,?)', 'comment_cursor_epoch', new Date().toISOString());
});
}
if (!fresh && migrateSchema) this.#migrateSchema();
if (
schemaDigest(this.#db) !== EXPECTED_DIGEST ||
this.get("SELECT value FROM meta WHERE key='schema_version'")?.value !== '3b' ||
this.get("SELECT value FROM meta WHERE key='schema_version'")?.value !== SCHEMA_VERSION ||
this.get("SELECT value FROM meta WHERE key='schema_digest'")?.value !== EXPECTED_DIGEST ||
this.get('PRAGMA journal_mode').journal_mode !== 'wal'
)
@@ -118,12 +146,41 @@ export class Store {
if (this.get('PRAGMA quick_check').quick_check !== 'ok' || this.all('PRAGMA foreign_key_check').length)
throw Error('integrity-failed');
this.#verifyTimes();
const epoch = this.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'")?.value;
if (!epoch || !Number.isFinite(Date.parse(epoch)) || new Date(epoch).toISOString() !== epoch)
throw Error('invalid-comment-epoch');
} catch (e) {
if (fd !== undefined) closeSync(fd);
this.close();
throw e;
}
}
// Explicit offline operation only. Normal startup refuses old versions without changing them.
#migrateSchema() {
let version = this.get("SELECT value FROM meta WHERE key='schema_version'")?.value;
while (version !== SCHEMA_VERSION) {
const step = migrations.find((m) => m.from === version);
if (
!step || schemaDigest(this.#db) !== step.fromDigest ||
this.get("SELECT value FROM meta WHERE key='schema_digest'")?.value !== step.fromDigest ||
this.get('PRAGMA journal_mode').journal_mode !== 'wal'
) throw Error('schema-mismatch');
if (this.get('PRAGMA quick_check').quick_check !== 'ok' || this.all('PRAGMA foreign_key_check').length)
throw Error('integrity-failed');
this.#verifyTimes();
this.transaction(() => {
this.#db.exec(step.sql);
// Only version metadata changes. The guard is restored before this step commits.
this.#db.exec('DROP TRIGGER meta_no_update');
this.run("UPDATE meta SET value=? WHERE key='schema_version'", step.to);
this.run("UPDATE meta SET value=? WHERE key='schema_digest'", step.toDigest);
step.initialize?.(this);
this.#db.exec("CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END");
if (schemaDigest(this.#db) !== step.toDigest) throw Error('schema-mismatch');
});
version = step.to;
}
}
get(sql, ...args) {
return this.#db.prepare(sql).get(...args);
}
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'comment-cursors-'));
const store = new Store(root);
const roles = { pm: { authority: { withinRole: ['message.send'], crossRole: [] } } };
const b = new Broker({ store, businesses: {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
} });
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
return { store, b, record: (commentCursors, extra = {}) => b.recordTask({ business: 'demo', commentCursors, ...extra }) };
}
test('comment cursors isolate businesses, move only forward and remain append-only', t => {
const { store, b, record } = fixture(t);
record([{ task_id: 7, last_comment: 11 }]);
record([{ task_id: 7, last_comment: 15 }]);
b.recordTask({ business: 'other', commentCursors: [{ task_id: 7, last_comment: 2 }] });
assert.deepEqual(b.taskView('demo', 'comments').cursors.map(c => ({ ...c })), [{ task_id: 7, last_comment: 15 }]);
assert.deepEqual(b.taskView('other', 'comments').cursors.map(c => ({ ...c })), [{ task_id: 7, last_comment: 2 }]);
for (const last_comment of [11, 15]) assert.throws(() => record([{ task_id: 7, last_comment }]), /must advance/);
for (const sql of [
'UPDATE task_comment_cursors SET last_comment=20 WHERE seq=1',
'DELETE FROM task_comment_cursors WHERE seq=1',
"INSERT OR REPLACE INTO task_comment_cursors VALUES(1,'demo',7,20)",
]) assert.throws(() => store.run(sql), /append-only/);
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 3);
});
test('only trusted sync writes validated cursors; events and cursor share rollback', t => {
const { store, b, record } = fixture(t);
const cap = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
for (const x of [null, {}, { task_id: 0, last_comment: 1 }, { task_id: 1, last_comment: 0 },
{ task_id: 1, last_comment: 1.5 }, { task_id: 1, last_comment: 2 ** 53 },
{ task_id: 1, last_comment: 1, extra: true }]) {
assert.throws(() => record([x]), e => e.code === 'invalid-request');
}
assert.throws(() => record([{ task_id: 1, last_comment: 1 }], { cap }), e => e.code === 'agent-required');
assert.throws(() => record([{ task_id: 1, last_comment: 1 }], {
events: [{ kind: 'human.input', body: {} }],
}), e => e.code === 'reserved-event');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
});
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, copyFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { Store, schemaDigest } from '../src/store.mjs';
function legacy(t) {
const root = mkdtempSync(join(tmpdir(), 'comment-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const source = join(root, 'schema-current.sqlite');
const db = new DatabaseSync(source);
const ddl = readFileSync(new URL('./fixtures/schema-3b.sql', import.meta.url), 'utf8');
assert.equal(createHash('sha256').update(ddl).digest('hex'),
'179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65');
db.exec(ddl);
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)')
.run('preserved', '2026-10-15T00:00:00.000Z', 'demo', 'action.allowed', '{"fixture":true}');
db.close();
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite');
copyFileSync(source, path);
chmodSync(path, 0o600);
return { root, source, path };
}
test('explicit 3b copy migration preserves evidence and opens as 3c; normal startup never migrates', t => {
const { root, source } = legacy(t);
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3c');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
assert.equal(new Date(epoch).toISOString(), epoch);
store.close();
assert.deepEqual(readFileSync(source), original);
const again = new Store(root, { migrateSchema: true });
assert.equal(again.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value, epoch);
again.close();
new Store(root).close();
});
test('migration refuses active writers and drifted legacy schemas, with no partial upgrade', t => {
const { root, path } = legacy(t);
const db = new DatabaseSync(path);
db.exec('DROP TRIGGER events_no_delete');
db.close();
assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/);
const check = new DatabaseSync(path);
assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3b');
assert.equal(check.prepare("SELECT count(*) n FROM sqlite_master WHERE name='task_comment_cursors'").get().n, 0);
check.close();
const other = legacy(t);
const current = new Store(other.root, { migrateSchema: true });
t.after(() => current.close());
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
});
test('named migration registry chains 3b through 3c to a fixture-only future version in order', async t => {
const { root } = legacy(t);
const { writeFileSync } = await import('node:fs');
const { pathToFileURL } = await import('node:url');
const moduleRoot = join(root, 'future-bus');
mkdirSync(join(moduleRoot, 'src'), { recursive: true });
mkdirSync(join(moduleRoot, 'src/migrations'));
const futureSQL = 'CREATE TABLE future_fixture (value TEXT) STRICT;\n';
const currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const futureDDL = currentDDL + futureSQL;
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3c';", "export const SCHEMA_VERSION = '3d';")
.replace('}];\nconst timedTables', `}, { from: '3c', to: '3d', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
future.close();
new FutureStore(root).close();
});
test('migration refuses mismatched source metadata without changing schema or evidence', t => {
const { root, path } = legacy(t);
const db = new DatabaseSync(path);
db.exec("DROP TRIGGER meta_no_update; UPDATE meta SET value='untrusted' WHERE key='schema_digest'; CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END");
db.close();
assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/);
const check = new DatabaseSync(path);
assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3b');
assert.equal(check.prepare('SELECT id FROM events').get().id, 'preserved');
check.close();
});
test('offline command migrates only the supplied fixture root and reports usage errors', async t => {
const { spawnSync } = await import('node:child_process');
const { root } = legacy(t);
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3c ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
assert.match(usage.stderr, /usage:/);
});
+211
View File
@@ -0,0 +1,211 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
+2 -2
View File
@@ -516,7 +516,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
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('table') - 1, 9);
assert.equal(count('trigger'), 39);
assert.equal(count('view'), 5);
});
+19
View File
@@ -427,3 +427,22 @@ run use a scratch container on 127.0.0.1, never a shared instance.
`deploy/vikunja/` has the compose file for installs without their own
Vikunja. See its README.
### Durable comment cursor (row 49)
The bus stores each business/task's highest observed comment ID separately from
`task_current` and its digest. Adapter startup restores those IDs before its
first reconcile. The first read of each task's comment list after startup catches
outage comments, even outside the normal `updated` window. A reconcile also
checks comment lists when the expanded count changes. Bot IDs advance the cursor
but do not produce external-comment counts. The cursor and event commit together;
in-memory ID, count and timestamp caches advance only after a successful commit.
A crash after commit reloads the durable cursor and does not repeat the event.
No cursor row is written until a new ID is observed. Empty tasks implicitly have
cursor zero; initial timestamps use the database's creation/migration epoch minus
a 60-second skew margin. This can duplicate comments at the first migration if
the tracker clock is ahead, and clocks more than a minute behind remain outside
the guarantee. Once a cursor exists, timestamps do not govern freshness.
See the bus README for the explicit offline schema migration and Mos's separate
approved downtime/re-pin procedure. No development seat migrates the live store.
+3 -1
View File
@@ -68,6 +68,7 @@ export function tasksAdapter({
const call = client({ baseUrl: s.baseUrl, fetch });
const use = (instance) => (method, path, opts) =>
credentials.use(`${name}/${instance}`, 'vikunja', (token) => call(token, method, path, opts));
const comments = broker.taskView(name, 'comments');
const ctx = {
...s,
broker,
@@ -86,7 +87,8 @@ export function tasksAdapter({
pending: 0,
lastStart: null,
lastReconcile: null,
lastComment: new Map(),
lastComment: new Map(comments.cursors.map((c) => [c.task_id, c.last_comment])),
commentEpoch: Date.parse(comments.epoch),
commentsAt: new Map(),
commentCount: new Map(),
files: Object.fromEntries(Object.entries(files(businesses[name])).map(([i, f]) => [i, { file: f, stamp: stamp(f) }])),
+18 -14
View File
@@ -79,12 +79,14 @@ export async function walk(ctx, filter, query = {}, project = ctx.project) {
const changed = (before, after) =>
Object.keys(after).filter((k) => JSON.stringify(before?.[k]) !== JSON.stringify(after[k]));
// Comments by anyone but this business's bots, newer than the last one seen. The first look at a
// task counts only comments created inside the window, so a restart doesn't replay old ones.
// task uses the database epoch minus the skew margin; later reads use durable IDs.
// `created` is to the second, so the bound is too.
async function foreignComments(ctx, id, sinceMs) {
async function foreignComments(ctx, id, out) {
const r = await all(ctx.syncCall, null, `/tasks/${id}/comments`, {}, ctx.per);
if (!r.ok) throw new BusError(refusal(r.r));
const since = Math.floor(sinceMs / 1000) * 1000;
// No cursor yet: allow one minute of tracker clock skew around creation/migration.
// Once an ID is durable, tracker timestamps never affect freshness.
const since = Math.floor((ctx.commentEpoch - WINDOW_MS) / 1000) * 1000;
const last = ctx.lastComment.get(id);
let n = 0,
top = last ?? 0;
@@ -94,7 +96,7 @@ async function foreignComments(ctx, id, sinceMs) {
const fresh = last === undefined ? Date.parse(c.created) >= since : c.id > last;
if (fresh && !ctx.bots.has(c.author.id)) n++;
}
ctx.lastComment.set(id, top);
if (top > (last ?? 0)) out.commentCursors.push({ task_id: id, last_comment: top });
return n;
}
// One task read. Records nothing that a newer self snapshot already covers, and nothing that
@@ -163,7 +165,9 @@ async function missing(ctx, current, refs, out) {
}
}
const record = (ctx, out) => {
if (out.snapshots.length || out.events.length) ctx.broker.recordTask({ business: ctx.business, ...out });
if (out.snapshots.length || out.events.length || out.commentCursors.length)
ctx.broker.recordTask({ business: ctx.business, ...out });
for (const c of out.commentCursors) ctx.lastComment.set(c.task_id, c.last_comment);
};
// A child that answers 403 or 404 may have left the tree after the last reconcile: no longer
// shared, moved under a project the bot can't read, or deleted. A 404 can also be the child's kanban
@@ -191,17 +195,16 @@ export async function tick(ctx) {
}
}
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
const out = { snapshots: [], events: [] };
const out = { snapshots: [], events: [], commentCursors: [] };
// Refs some read this tick placed. Keyed by ref, not id: a task moved between two projects of the
// tree shows on the new one's board under the same id, and the old ref still needs its tombstone.
const placed = new Set();
for (const { project, view, open, hits } of reads) {
const comments = new Map();
for (const [id, t] of hits) {
const key = normal(t.updated);
if (ctx.commentsAt.get(id) === key) continue;
comments.set(id, await foreignComments(ctx, id, sinceMs));
ctx.commentsAt.set(id, key);
if (ctx.commentsAt.get(id) === normal(t.updated)) continue;
comments.set(id, await foreignComments(ctx, id, out));
// Cache advances only after the database transaction commits.
}
for (const [id, { task, bucket }] of open) {
const hit = hits.get(id);
@@ -224,6 +227,7 @@ export async function tick(ctx) {
.filter((r) => !placed.has(r));
await missing(ctx, current, gone, out);
record(ctx, out);
for (const { hits } of reads) for (const [id, t] of hits) ctx.commentsAt.set(id, normal(t.updated));
ctx.lastStart = startMs;
return { snapshots: out.snapshots.length, events: out.events.length };
}
@@ -234,13 +238,12 @@ export async function tick(ctx) {
export async function reconcile(ctx) {
const startMs = ctx.clock();
const readAt = iso(startMs);
const sinceMs = ctx.lastReconcile ?? startMs;
if (ctx.descendants) await discover(ctx);
const reads = [];
for (const [project, view] of ctx.tree)
reads.push({ project, view, open: await openTasks(ctx, project, view), list: await walk(ctx, '', { expand: 'comment_count' }, project) });
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
const out = { snapshots: [], events: [] };
const out = { snapshots: [], events: [], commentCursors: [] };
const listed = new Set();
for (const { project, view, open, list } of reads)
for (const t of list) {
@@ -251,8 +254,7 @@ export async function reconcile(ctx) {
const count = t.comment_count;
if (count !== undefined && !Number.isSafeInteger(count)) shape();
const before = ctx.commentCount.get(t.id);
if (count !== undefined) ctx.commentCount.set(t.id, count);
if (before !== undefined && count !== before) comments = await foreignComments(ctx, t.id, sinceMs);
if (before === undefined || count !== before) comments = await foreignComments(ctx, t.id, out);
consider(ctx, current, project, { task: t, bucket, via: 'reconcile', readAt, comments }, out);
}
const gone = [...current.values()]
@@ -260,6 +262,8 @@ export async function reconcile(ctx) {
.map((x) => x.task_ref);
await missing(ctx, current, gone, out);
record(ctx, out);
for (const { list } of reads)
for (const t of list) if (t.comment_count !== undefined) ctx.commentCount.set(t.id, t.comment_count);
ctx.lastReconcile = startMs;
ctx.lastStart ??= startMs;
return { snapshots: out.snapshots.length, events: out.events.length };
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { world } from './world.mjs';
async function fixture(t) {
let now = Date.now();
const w = await world(t, { fake: { now: () => now }, adapter: { clock: () => now } });
await w.adapter.start('demo');
const owner = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'watched' });
assert.equal(owner.status, 201);
await w.adapter.reconcile('demo');
assert.equal(w.store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const events = () => w.events('task.changed.external').filter(e => e.body.comments);
return { w, events, advance: () => { now += 3 * 3600000; } };
}
test('stopped bus resumes an empty task cursor after a long outage, exactly once', async t => {
const { w, events, advance } = await fixture(t);
await w.stop();
advance();
assert.equal((await w.ui('POST', '/tasks/1/comments', { comment: 'while bus stopped' })).status, 201);
advance();
await w.restart();
assert.equal(events().length, 1);
assert.equal(events()[0].body.comments, 1);
await w.adapter.tick('demo');
await w.adapter.reconcile('demo');
assert.equal(events().length, 1);
});
test('persisted IDs avoid replay, exclude bots and bound rows to observed new comments', async t => {
const { w, events, advance } = await fixture(t);
await w.ui('POST', '/tasks/1/comments', { comment: 'before restart' });
await w.adapter.reconcile('demo');
assert.equal(events().length, 1);
const rows = () => w.store.get('SELECT count(*) n FROM task_comment_cursors').n;
assert.equal(rows(), 1);
await w.adapter.reconcile('demo');
await w.adapter.tick('demo');
assert.equal(rows(), 1);
await w.stop();
advance();
const r = await w.fake.fetch('http://vikunja.test/api/v2/tasks/1/comments', {
method: 'POST', headers: { authorization: 'Bearer ' + w.tokens.coder, 'content-type': 'application/json' },
body: JSON.stringify({ comment: 'bot while stopped' }),
});
assert.equal(r.status, 201);
await w.ui('POST', '/tasks/1/comments', { comment: 'human while stopped' });
advance();
await w.restart();
assert.equal(events().length, 2);
assert.equal(events()[1].body.comments, 1);
assert.equal(rows(), 2);
await w.stop();
await w.restart();
await w.adapter.tick('demo');
assert.equal(events().length, 2);
assert.equal(rows(), 2);
});
test('commit followed by crash before cache advance neither loses nor duplicates a comment', async t => {
const { w, events } = await fixture(t);
await w.ui('POST', '/tasks/1/comments', { comment: 'committed before crash' });
const record = w.broker.recordTask.bind(w.broker);
w.broker.recordTask = args => { record(args); throw Error('simulated crash after commit'); };
await assert.rejects(w.adapter.reconcile('demo'), /simulated crash/);
assert.equal(events().length, 1);
await w.stop();
await w.restart();
assert.equal(events().length, 1);
await w.adapter.tick('demo');
assert.equal(events().length, 1);
});
test('failed event insert rolls back cursor and a retry still emits the comment once', async t => {
const { w, events } = await fixture(t);
await w.ui('POST', '/tasks/1/comments', { comment: 'retry me' });
w.store.run("CREATE TRIGGER fixture_fail BEFORE INSERT ON events WHEN NEW.kind='task.changed.external' BEGIN SELECT RAISE(ABORT,'fixture failure'); END");
await assert.rejects(w.adapter.reconcile('demo'), /fixture failure/);
w.store.run('DROP TRIGGER fixture_fail');
await w.adapter.reconcile('demo');
assert.equal(events().length, 1);
assert.equal(events()[0].body.comments, 1);
});
// Removing the epoch margin loses a tracker comment thirty seconds behind host time.
test('initial cursor accepts a comment within the stated one-minute clock skew margin', async t => {
let trackerNow = Date.now() - 30000;
const skewed = await world(t, { fake: { now: () => trackerNow } });
await skewed.adapter.start('demo');
await skewed.ui('POST', `/projects/${skewed.project}/tasks`, { title: 'clock skew' });
await skewed.adapter.reconcile('demo');
await skewed.ui('POST', '/tasks/1/comments', { comment: 'thirty seconds behind' });
await skewed.adapter.reconcile('demo');
const found = skewed.events('task.changed.external').filter(e => e.body.comments);
assert.equal(found.length, 1);
assert.equal(found[0].body.comments, 1);
await skewed.adapter.reconcile('demo');
assert.equal(skewed.events('task.changed.external').filter(e => e.body.comments).length, 1);
});
+17 -6
View File
@@ -104,8 +104,8 @@ export async function world(t, edit = {}) {
for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials;
if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials;
const credentials = new Credentials({ references, env, dataRoot: root });
const store = new Store(root);
const broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) });
let store = new Store(root);
let broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) });
const log = [];
const factory = tasksAdapter({
trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } },
@@ -114,7 +114,7 @@ export async function world(t, edit = {}) {
autostart: false,
...edit.adapter,
});
const adapter = await factory({ broker, credentials, businesses });
let adapter = await factory({ broker, credentials, businesses });
t.after(async () => {
await adapter.close();
store.close();
@@ -145,6 +145,17 @@ export async function world(t, edit = {}) {
store.all('SELECT * FROM task_snapshots WHERE task_ref=? ORDER BY seq', taskRef).map((s) => ({ ...s, fields: JSON.parse(s.fields) }));
const call = (cap, verb, args) => broker.requestTask(cap, { verb, args }, adapter.handle);
return {
async stop() {
await adapter.close();
store.close();
},
async restart() {
store = new Store(root);
broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) });
adapter = await factory({ broker, credentials, businesses });
await adapter.start('demo');
if (adapter.status()[0].state !== 'ready') throw Error(adapter.status()[0].refused);
},
root,
fake,
owner,
@@ -155,9 +166,9 @@ export async function world(t, edit = {}) {
tokens,
credentials,
businesses,
store,
broker,
adapter,
get store() { return store; },
get broker() { return broker; },
get adapter() { return adapter; },
log,
caps,
agent,
+2 -6
View File
@@ -10,18 +10,14 @@
// "refuse<TAB>reason<TAB>detail" line and exit 2. A missing or unsafe
// database or business file exits 3.
//
// Kept in step with packages/bus/src/store.mjs, which this imports from:
// the schema_version literal '3b' below is a copy of the Store's. A schema
// bump changes it there, and until it changes here every read refuses
// bus-schema.
// Schema version and digest follow the Store exports; a schema bump has one authority.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { schemaDigest, SCHEMA_SHA256 } from '../../packages/bus/src/store.mjs';
import { schemaDigest, SCHEMA_SHA256, SCHEMA_VERSION } from '../../packages/bus/src/store.mjs';
const SCHEMA_VERSION = '3b';
const MERGE = 'git.merge.protected';
const WORK_REF = /^work\/vikunja-([1-9][0-9]*)-([1-9][0-9]*)$/;
const ANY_WORK_REF = /\bwork\/[A-Za-z0-9._/-]*[A-Za-z0-9_]/g;