Files

1288 lines
70 KiB
Diff

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 @@
// "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;
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);
+});