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.
118 lines
6.6 KiB
JavaScript
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:/);
|
|
});
|