Files
stack/packages/bus/tests/comment-migration.test.mjs
jason.woltje 3e21d239b3 feat(bus): owner project archive and unarchive (#797)
Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.

- project.archive / project.unarchive: human-only (project.create's gate
  minus the tester carve-out; tester, user and agent refused). State is the
  latest row of a new append-only project_archives journal (3s->3t, additive;
  the rows are the audit trail), so projects itself never changes and nothing
  is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
  flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
  project-archived; every existing record stays readable and unarchive
  restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
  routes (chatHTTP's write fence); chat.js gains two one-line hooks,
  index.html one script and one link tag; styling only through the shell's
  .project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
  step-count pins and migration fixtures updated for 3t.

bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
2026-11-05 23:50:51 -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, '3t');
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 = '3t';", "export const SCHEMA_VERSION = '3u';")
.replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', 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'])
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, '3u');
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 3t ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
assert.match(usage.stderr, /usage:/);
});