Jason's 2026-11-12 ruling: the only deployed system is the dev environment on dragon-lin, so the system-type plug-in kind goes with no retired kind, fence, conversion or new refusal code. - plugins: system-type leaves KINDS; system-types.mjs and the five modules are deleted. An org file naming the kind refuses unknown-kind. - organization: the plugins.system-type check, systemTypesFence and the system-types.console right are removed. - bus: system-types.console, project.system-types[.set], the system-type half of the tester check, the runtime reads and system-type-not-enabled are removed. Step 3v-3w drops project_system_types, its index, its four guards and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views). - cli: mosaic org system-types and mosaic project system-types[-set] are removed; stack-release COUNTED and the budget supervisor's closed list move to 3w. - channel: the project.system-types verbs are removed. - webui: the System types page, route, dashboard row, chips and project selection are removed. - docs: REQ-SYS-1/2 now describe starter packs; README system-type text and the requirements titles follow.
359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
import { DatabaseSync } from 'node:sqlite';
|
|
import {
|
|
readFileSync,
|
|
mkdirSync,
|
|
lstatSync,
|
|
openSync,
|
|
closeSync,
|
|
writeFileSync,
|
|
fsyncSync,
|
|
unlinkSync,
|
|
} from 'node:fs';
|
|
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 = '1c305d842619360c14c8388cd43cb9de81afbe46ff0662fd5de9851be9ff3ea0';
|
|
export const SCHEMA_VERSION = '3w';
|
|
// 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(),
|
|
),
|
|
}, {
|
|
from: '3c',
|
|
to: '3d',
|
|
sql: readFileSync(new URL('./migrations/3c-3d.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
from: '3d',
|
|
to: '3e',
|
|
sql: readFileSync(new URL('./migrations/3d-3e.sql', import.meta.url), 'utf8'),
|
|
// Messages stay as written. Undelivered ones have no thread, so a thread run never receives them.
|
|
// The store doesn't know which roles are thread instances, so it reports every role's count.
|
|
report: (store) => store.all(
|
|
"SELECT business, to_role, count(*) AS n FROM messages m WHERE NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) GROUP BY business, to_role ORDER BY business, to_role",
|
|
).map((r) => `kept ${r.n} undelivered message(s) for ${r.business}/${r.to_role} without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send`),
|
|
}, {
|
|
from: '3e',
|
|
to: '3f',
|
|
sql: readFileSync(new URL('./migrations/3e-3f.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 141 (#1635): instance names, and the name a message was sent to. Messages keep a null to_name.
|
|
from: '3f',
|
|
to: '3g',
|
|
sql: readFileSync(new URL('./migrations/3f-3g.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
from: '3g',
|
|
to: '3h',
|
|
sql: readFileSync(new URL('./migrations/3g-3h.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 126 (#1620): usage records carry the run's account; the table is rebuilt without its CHECK.
|
|
from: '3h',
|
|
to: '3i',
|
|
sql: readFileSync(new URL('./migrations/3h-3i.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 234 (#1740): enrolled hosts, host grants and the grant event guard. Only new objects.
|
|
from: '3i',
|
|
to: '3j',
|
|
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 236 (#1742, #436b section 7): the Gitea users the stack made and the Gitea grants. Only new tables.
|
|
from: '3j',
|
|
to: '3k',
|
|
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 264a (#1769): sessions below a thread. Each thread gets a default session whose id is the
|
|
// thread id, and thread_claims, message_threads and thread_opens are rebuilt to name it.
|
|
from: '3k',
|
|
to: '3l',
|
|
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
|
|
from: '3l',
|
|
to: '3m',
|
|
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Row 271 (#1776): clear context. The clear journal and the cancelled-message receipts. Only new tables.
|
|
from: '3m',
|
|
to: '3n',
|
|
sql: readFileSync(new URL('./migrations/3m-3n.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// #797 / #1839 Task 1: append-only request lifecycle evidence. Explicit offline migration only.
|
|
from: '3n',
|
|
to: '3o',
|
|
sql: readFileSync(new URL('./migrations/3n-3o.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// #797 / #1839 Task 2: the request journal's bootstrap.stale and bootstrap.completed kinds. request_events
|
|
// is rebuilt with the wider kind list and keeps every row, seq and guard.
|
|
from: '3o',
|
|
to: '3p',
|
|
sql: readFileSync(new URL('./migrations/3o-3p.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
from: '3p', to: '3q',
|
|
sql: readFileSync(new URL('./migrations/3p-3q.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// #797 / #1839 Task 3: the request journal's question.asked, question.answered and attach.selected kinds, for the
|
|
// Console intake controls, added to the kind list 3p-3q left (which already holds the PRD kinds and mode.changed).
|
|
// request_events is rebuilt with the union and keeps every row, seq and guard.
|
|
from: '3q',
|
|
to: '3r',
|
|
sql: readFileSync(new URL('./migrations/3q-3r.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// Task 6b: checked advancement after Task 3's complete fifteen-kind union.
|
|
from: '3r', to: '3s',
|
|
sql: readFileSync(new URL('./migrations/3r-3s.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// #797 MVP3: the owner's reversible project archive. Only a new append-only table, its index
|
|
// and its guards; no existing row changes and nothing is deleted.
|
|
from: '3s', to: '3t',
|
|
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
from: '3t', to: '3u',
|
|
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// U4a (#1873): the task store built into each organization, the default task-tracker module.
|
|
// Only a new append-only table, its index and its guards; no existing row changes.
|
|
from: '3u', to: '3v',
|
|
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
|
|
}, {
|
|
// SP9 (#1873, Jason's ruling 2026-11-12): drops the table 3t-3u created, its index and its four
|
|
// guards, with its rows.
|
|
from: '3v', to: '3w',
|
|
sql: readFileSync(new URL('./migrations/3v-3w.sql', import.meta.url), 'utf8'),
|
|
}];
|
|
const timedTables = [
|
|
'events',
|
|
'role_claims',
|
|
'decisions',
|
|
'decision_events',
|
|
'messages',
|
|
'deliveries',
|
|
'task_snapshots',
|
|
'projects',
|
|
'threads',
|
|
'sessions',
|
|
'session_defaults',
|
|
'thread_claims',
|
|
'thread_opens',
|
|
'thread_shares',
|
|
'instance_names',
|
|
'usage_records',
|
|
'session_ops',
|
|
'message_cancels',
|
|
'request_events',
|
|
'request_effects',
|
|
'request_snapshots',
|
|
'request_snapshot_events',
|
|
'request_project_events',
|
|
'project_archives',
|
|
'store_tasks',
|
|
];
|
|
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
|
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
|
export const schemaDigest = (db) =>
|
|
hex(
|
|
db
|
|
.prepare('SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type,name')
|
|
.all()
|
|
.map((r) => `${r.type}|${r.name}|${r.sql}`)
|
|
.join('\n'),
|
|
);
|
|
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);
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') return null;
|
|
throw e;
|
|
}
|
|
}
|
|
function safe(path, directory = false) {
|
|
const s = lstatSync(path);
|
|
if (
|
|
s.isSymbolicLink() ||
|
|
!(directory ? s.isDirectory() : s.isFile()) ||
|
|
s.uid !== process.getuid() ||
|
|
s.mode & 0o077
|
|
)
|
|
throw Error('unsafe-path');
|
|
return s;
|
|
}
|
|
|
|
// Internal trusted SQL surface. Only the broker owns a Store; never expose SQL over the socket.
|
|
export class Store {
|
|
#db;
|
|
#lock;
|
|
#lockStat;
|
|
#closed = false;
|
|
#inTransaction = false;
|
|
#report = [];
|
|
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())
|
|
throw Error('unsafe-path');
|
|
this.directory = join(dataRoot, 'bus');
|
|
if (!exists(this.directory)) mkdirSync(this.directory, { mode: 0o700 });
|
|
safe(this.directory, true);
|
|
this.#lock = join(this.directory, 'writer.lock');
|
|
let fd;
|
|
try {
|
|
fd = openSync(this.#lock, 'wx', 0o600);
|
|
} catch (e) {
|
|
if (e.code === 'EEXIST') throw Error('writer-locked');
|
|
throw e;
|
|
}
|
|
this.#lockStat = lstatSync(this.#lock);
|
|
try {
|
|
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
|
|
fsyncSync(fd);
|
|
closeSync(fd);
|
|
fd = undefined;
|
|
const dirfd = openSync(this.directory, 'r');
|
|
try {
|
|
fsyncSync(dirfd);
|
|
} finally {
|
|
closeSync(dirfd);
|
|
}
|
|
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);
|
|
this.#db = new DatabaseSync(this.path, { timeout: 5000, defensive: true });
|
|
this.#db.exec('PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL');
|
|
if (fresh) {
|
|
// journal_mode must be set outside a transaction.
|
|
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', 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 !== SCHEMA_VERSION ||
|
|
this.get("SELECT value FROM meta WHERE key='schema_digest'")?.value !== EXPECTED_DIGEST ||
|
|
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();
|
|
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.#report.push(...(step.report?.(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;
|
|
}
|
|
}
|
|
// Lines from the last migrateSchema, for migrate-schema.mjs to print.
|
|
get migrationReport() {
|
|
return [...this.#report];
|
|
}
|
|
get(sql, ...args) {
|
|
return this.#db.prepare(sql).get(...args);
|
|
}
|
|
all(sql, ...args) {
|
|
return this.#db.prepare(sql).all(...args);
|
|
}
|
|
run(sql, ...args) {
|
|
if (!this.#inTransaction) return this.transaction(() => this.run(sql, ...args));
|
|
return this.#db.prepare(sql).run(...args);
|
|
}
|
|
#verifyTimes() {
|
|
for (const table of timedTables) {
|
|
// A migration checks older schemas that lack later tables; the digest checks decide which must exist.
|
|
if (!this.get("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", table)) continue;
|
|
const badAt =
|
|
"length(at)<>24 OR at NOT GLOB '[0-9][0-9][0-9][0-9]-*' OR strftime('%Y-%m-%dT%H:%M:%fZ',at) IS NOT at";
|
|
const badRead =
|
|
table === 'task_snapshots'
|
|
? " OR (read_at IS NOT NULL AND (length(read_at)<>24 OR read_at NOT GLOB '[0-9][0-9][0-9][0-9]-*' OR strftime('%Y-%m-%dT%H:%M:%fZ',read_at) IS NOT read_at))"
|
|
: '';
|
|
if (this.get(`SELECT 1 FROM ${table} WHERE ${badAt}${badRead} LIMIT 1`))
|
|
throw Error('invalid-timestamp');
|
|
}
|
|
}
|
|
transaction(fn) {
|
|
if (typeof fn !== 'function' || fn.constructor.name === 'AsyncFunction')
|
|
throw Error('async-transaction-refused');
|
|
if (this.#inTransaction) throw Error('nested-transaction');
|
|
this.#db.exec('BEGIN IMMEDIATE');
|
|
this.#inTransaction = true;
|
|
try {
|
|
const result = fn();
|
|
if (result?.then) throw Error('async-transaction-refused');
|
|
this.#verifyTimes();
|
|
this.#db.exec('COMMIT');
|
|
return result;
|
|
} catch (e) {
|
|
this.#db.exec('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
this.#inTransaction = false;
|
|
}
|
|
}
|
|
close() {
|
|
if (this.#closed) return;
|
|
this.#closed = true;
|
|
this.#db?.close();
|
|
const current = exists(this.#lock);
|
|
if (current?.ino === this.#lockStat?.ino && current?.dev === this.#lockStat?.dev) unlinkSync(this.#lock);
|
|
}
|
|
}
|