Files
stack/packages/bus/tests/comment-migration.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

118 lines
6.6 KiB
JavaScript

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 3g; 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, '3v');
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 3g 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 = '3v';", "export const SCHEMA_VERSION = '3w';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', 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', '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', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.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, '3w');
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 3v ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
assert.match(usage.stderr, /usage:/);
});