Files
stack/packages/bus/tests/thread-shares.test.mjs
jason.woltje 9b7bf11afb feat(tasks): U4a, a task store built into each organization (#1873)
PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its
tasks in its own bus through the new task-tracker plug-in kind. The
default module is the built-in store; Vikunja is the optional module
and a Vikunja organization keeps today's path.

- bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only,
  one row per write, each citing its events row; seven guard triggers.
- broker: the eight role verbs on the store with the slice 1 field
  table checked twice (module, then a before/after field diff); the
  person's tasks.create/edit/archive/unarchive/delete as human.input
  events; the tasks read returns store rows in board order (group,
  position), filtered and paged like tracker rows.
- runtime: each business gets its module at start (organization file
  plugin, else vikunja with a tracker entry, else store); a store
  business with a Vikunja tracker refuses the boot (tracker-config).
- plugins: task-tracker kind with store and vikunja modules.
- cli: the launcher's tracker flag covers store organizations; trail
  prints store writes.
- pins: schema.test counts, the bus migration tests, stack-release #811
  and T46 (store_tasks on the always list; COUNTED unchanged),
  budget-supervisor accepts 3v.

Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
2026-11-09 19:25:44 -06:00

466 lines
29 KiB
JavaScript

// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const here = (p) => new URL(p, import.meta.url);
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
// As user-principal.test.mjs: the access document is a function each test edits between requests.
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => {
if (access.fail) throw new Error('access file refused');
return access.doc;
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const p1 = call('human', 'project.create', { name: 'shared' }).id;
const p2 = call('human', 'project.create', { name: 'private' }).id;
// alice and bob hold collaborator grants on p1, carol on p2 only.
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
access.doc.grants = base();
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
}
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
const { call, code, A, p1, rows, read } = setup(t);
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
const [listed] = call('bob', 'thread.list', { shared: true });
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
// The plain list keeps its shape and holds only bob's own threads.
assert.deepEqual(call('bob', 'thread.list'), []);
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
for (const [verb, args] of [
['thread.send', { body: 'bob was here' }],
['thread.open', {}],
['thread.share', { to: '*' }],
['thread.share', { to: 'jason' }],
['thread.unshare', { to: 'user:bob' }],
['thread.shares', {}],
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
assert.equal(rows(), 1);
assert.deepEqual(read('alice', A), ['alice: hello']);
// The share is bob's alone: the owner and carol still can't read.
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
});
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
const { call, code, A, H, rows, read } = setup(t);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(read('human', A), ['alice: hello']);
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
assert.equal(rows(), 2);
});
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
const { access, call, code, base, A, p2, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
// A grant on another instance is the same.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
access.doc.grants = base();
assert.deepEqual(read('bob', A), ['alice: hello']);
// Removed, expired or an unreadable file: the gate refuses bob's next request.
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('bob', A), ['alice: hello'], what);
}
});
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
const { access, call, code, base, A, sent, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
assert.deepEqual(read('human', A), ['alice: hello']);
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('human', A), ['alice: hello'], what);
}
// A viewer grant still covers: rule 3 asks for any level.
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('human', A), ['alice: hello']);
});
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
const { store, call, code, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
{ op: 'share', target: 'user:bob', by: 'user:alice' },
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
{ op: 'share', target: 'user:bob', by: 'user:alice' },
]);
// An unshare of `*` leaves bob's own share in force.
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.unshare', { thread: A, to: '*' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(rows(), 5);
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { b, caps, access, call, code, base, A, p1, rows, read } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, and the viewer reads the share (row 275).
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
caps.vic = b.bindUser({ business: 'demo', user: 'vic' });
assert.deepEqual(read('vic', A), ['alice: hello']);
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a viewer reads a thread shared with it, and share, unshare and shares refuse a viewer access-refused (design 4.3)', (t) => {
const { access, call, code, base, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
// bob drops to viewer and still reads the share.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('bob', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// alice drops to viewer too: she reads her own thread, and each share verb on it refuses.
access.doc.grants = base().map((g) => ({ ...g, level: 'viewer' }));
assert.deepEqual(read('alice', A), ['alice: hello']);
for (const [verb, args] of [['thread.share', { to: '*' }], ['thread.unshare', { to: 'user:bob' }], ['thread.shares', {}]])
assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb);
assert.equal(rows(), 1);
// Rule 3 asks any level, so alice's viewer grant keeps the thread readable to bob.
assert.deepEqual(read('bob', A), ['alice: hello']);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
for (const to of ['jason', 'user:x y'])
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
assert.equal(rows(), 0);
// The owner shares its own threads only; a user's thread refuses the business human too.
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
assert.equal(rows(), 0);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
});
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
const { access, call, code, base, A, p2, rows } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
assert.equal(rows(), 1);
});
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
const { store, call, code, A, H, rows } = setup(t);
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
const was = refusals();
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
for (const thread of [T, H, A]) {
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
}
// Each refusal is recorded, as every refused request is.
assert.equal(refusals() - was, 18);
assert.equal(rows(), 0);
// A share with `*` of the owner's thread doesn't reach the tester's reads.
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
const { store, call, code, A, sent } = setup(t);
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const reply = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
);
// A new thread's default session has its own id (only a migrated thread's reuses the thread's).
const { session } = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', A);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, session);
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
call('alice', 'thread.share', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
}
// The tester never reads another's thread (rule 4).
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
});
test('a share covers every session of the thread', (t) => {
const { store, call, code, H, read } = setup(t);
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
call('human', 'thread.share', { thread: H, to: 'user:alice' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
});
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
const { call, code, A, H } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
// The owners still list their own.
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
});
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
const { b, caps, call, code, A, H, sent } = setup(t);
// The assistant run serving alice's thread, and a chief run with no thread.
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
for (const who of ['agent', 'chief']) call(who, 'role.claim');
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.share', { thread: A, to: 'jason' });
// The human principals now read it; the agents and the tester see what they saw before.
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
for (const who of ['agent', 'chief']) {
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
}
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
const AT = '2026-10-22T00:00:00.000Z';
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
function source3l(t) {
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
db.exec(ddl3l);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
}
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
db.close();
chmodSync(path, 0o600);
return { root, path };
}
const open = (path, fn) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return fn(db); } finally { db.close(); }
};
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
// Every table's rows but meta's, whose version and digest the step changes.
const tables = (path) => open(path, (db) => Object.fromEntries(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
));
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
const { root, path } = source3l(t);
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
assert.deepEqual(after, before);
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// No existing thread is shared: each stays private to its owner.
const store = new Store(root);
t.after(() => store.close());
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
for (const [sql, args] of [
["UPDATE thread_shares SET target='*'", []],
['DELETE FROM thread_shares', []],
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
});
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
async function engine3l(root) {
const moduleRoot = join(root, 'engine-3l');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3l(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = tables(path);
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
const { root: old } = source3l(t);
new Store3l(old).close();
});