diff --git a/packages/bus/README.md b/packages/bus/README.md index 4694e08b5..2973590c5 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -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, diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index 6aef89004..e68424252 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -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; diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index f10e11b37..7c281d635 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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); diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 755e43041..0c4d14f2d 100644 --- a/packages/bus/src/store.mjs +++ b/packages/bus/src/store.mjs @@ -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); } diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs index 4d00d56f4..5ef0c87dc 100644 --- a/packages/bus/tests/schema.test.mjs +++ b/packages/bus/tests/schema.test.mjs @@ -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); }); diff --git a/packages/tasks/README.md b/packages/tasks/README.md index f262ba339..0e19ebc91 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -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. diff --git a/packages/tasks/src/adapter.mjs b/packages/tasks/src/adapter.mjs index f0f8cacfe..6a1f77e97 100644 --- a/packages/tasks/src/adapter.mjs +++ b/packages/tasks/src/adapter.mjs @@ -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) }])), diff --git a/packages/tasks/src/sync.mjs b/packages/tasks/src/sync.mjs index 852d2b2cf..0f95d6741 100644 --- a/packages/tasks/src/sync.mjs +++ b/packages/tasks/src/sync.mjs @@ -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 }; diff --git a/packages/tasks/tests/world.mjs b/packages/tasks/tests/world.mjs index 5085e7798..3fa6c68e3 100644 --- a/packages/tasks/tests/world.mjs +++ b/packages/tasks/tests/world.mjs @@ -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, diff --git a/scripts/land-work/read.mjs b/scripts/land-work/read.mjs index 495a069eb..2402ed440 100644 --- a/scripts/land-work/read.mjs +++ b/scripts/land-work/read.mjs @@ -10,18 +10,14 @@ // "refusereasondetail" 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; diff --git a/packages/bus/src/migrate-schema.mjs b/packages/bus/src/migrate-schema.mjs new file mode 100644 index 000000000..b6b3893f4 --- /dev/null +++ b/packages/bus/src/migrate-schema.mjs @@ -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; + } +} diff --git a/packages/bus/src/migrations/3b-3c.sql b/packages/bus/src/migrations/3b-3c.sql new file mode 100644 index 000000000..ba241af7c --- /dev/null +++ b/packages/bus/src/migrations/3b-3c.sql @@ -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; diff --git a/packages/bus/src/migrations/3b.sql b/packages/bus/src/migrations/3b.sql new file mode 100644 index 000000000..6aef89004 --- /dev/null +++ b/packages/bus/src/migrations/3b.sql @@ -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')); diff --git a/packages/bus/tests/comment-cursors.test.mjs b/packages/bus/tests/comment-cursors.test.mjs new file mode 100644 index 000000000..536dc1d1f --- /dev/null +++ b/packages/bus/tests/comment-cursors.test.mjs @@ -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); +}); diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs new file mode 100644 index 000000000..8d730e1eb --- /dev/null +++ b/packages/bus/tests/comment-migration.test.mjs @@ -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:/); +}); diff --git a/packages/bus/tests/fixtures/schema-3b.sql b/packages/bus/tests/fixtures/schema-3b.sql new file mode 100644 index 000000000..6aef89004 --- /dev/null +++ b/packages/bus/tests/fixtures/schema-3b.sql @@ -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')); diff --git a/packages/tasks/tests/comment-restart.test.mjs b/packages/tasks/tests/comment-restart.test.mjs new file mode 100644 index 000000000..c3dfa12fb --- /dev/null +++ b/packages/tasks/tests/comment-restart.test.mjs @@ -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); +});