Framework Schema
Framework-defined SQL schema (audit / sessions / api_keys / cache / break-glass / scheduler-ticks / pubsub / rate-limit / seeders / etc.) — declarative, migration-aware, and dialect-portable across Postgres and SQLite.
When cluster mode is active the framework's audit chain, consent log, audit checkpoints, audit tip, scheduler ticks, rate-limit counters, pubsub fan-out, sessions, jobs, cache, seeders, and break-glass policies/grants live in the operator's external database (configured via b.externalDb.init). This module owns the DDL for those tables and exposes a single idempotent entry point — b.frameworkSchema.ensureSchema — that operators (or the framework's leader-acquire hook in a later release) call to create them at boot.
External-db tables are prefixed with _blamejs_ so they never collide with the operator's application tables:
audit_log — local-SQLite name _blamejs_audit_log — external-db name
b.frameworkSchema.tableName exposes the mapping so write- dispatch code (cluster-storage.js) can use a single name reference. b.frameworkSchema.LOCAL_TO_EXTERNAL is the frozen read-only mapping object.
Append-only WORM enforcement: ensureSchema installs BEFORE DELETE / BEFORE UPDATE triggers on audit_log, consent_log, and audit_checkpoints — Postgres via plpgsql RAISE EXCEPTION functions, MySQL via SIGNAL SQLSTATE '45000', SQLite via RAISE(ABORT, ...). Idempotent across reboots; any operator-applied DROP TRIGGER is restored on the next ensureSchema pass.
Dialect portability: postgres, mysql, and sqlite are all supported targets. The integer token is BIGINT on Postgres + MySQL (a 32-bit INTEGER overflows a Date.now() ms-epoch value) and INTEGER on SQLite; the binary token is BYTEA / LONGBLOB / BLOB. TEXT columns that participate in a PRIMARY KEY or index become VARCHAR(191) on MySQL (which refuses an unbounded TEXT/BLOB in a key) and stay plain TEXT on Postgres + SQLite.
b.frameworkSchema.setTablePrefix(prefix) #
Set the leading prefix applied to every framework-owned table name (audit / consent / sessions / jobs / cache / break-glass / …). The default is _blamejs_; pass a different value to namespace the framework's tables away from an operator schema that would otherwise collide. Config-time only — call it once, before schema creation (b.db.init calls it for you when you pass tablePrefix). Throws a FrameworkSchemaError ("framework-schema/invalid-prefix") when the prefix is not a non-empty SQL identifier, so a typo surfaces at boot rather than as a silently-misnamed table.
The default-prefix output is byte-identical to the historical names, so leaving the prefix unchanged is a no-op.
b.frameworkSchema.setTablePrefix("acme_");
b.frameworkSchema.tableName("audit_log");
// → "acme_audit_log"
try { b.frameworkSchema.setTablePrefix(""); }
catch (e) { e.code; } // → "framework-schema/invalid-prefix"
b.frameworkSchema.getTablePrefix() #
Return the prefix currently applied to framework-owned table names — _blamejs_ unless setTablePrefix changed it.
b.frameworkSchema.getTablePrefix();
// → "_blamejs_"
b.frameworkSchema.tableName(localName) #
Translate a local-SQLite table name into the external-db name. The mapping is the frozen LOCAL_TO_EXTERNAL object — tables that already carry the framework prefix locally pass through the mapping unchanged. The resolved name's leading prefix is then swapped to the configured prefix (setTablePrefix); with the default _blamejs_ prefix the output is byte-identical to the historical names. Cluster write-dispatch code uses this lookup so the same SQL works against both backends without per-call branching.
b.frameworkSchema.tableName("audit_log");
// → "_blamejs_audit_log"
b.frameworkSchema.tableName("_blamejs_sessions");
// → "_blamejs_sessions"
b.frameworkSchema.tableName("operator_app_table");
// → "operator_app_table"
b.frameworkSchema.coerceRow(row) #
Normalize one driver-returned framework row to a type-stable JS shape using COLUMN_TYPES, so a framework column reads identically on every backend: int columns become JS numbers (node-postgres hands BIGINT back as a string), blob columns become Buffers. text columns and any column NOT in the framework schema (operator tables, computed aliases) pass through untouched; null stays null. Idempotent — safe to call on an already-coerced or SQLite-shaped row. Mutates and returns the row.
A BIGINT beyond Number.MAX_SAFE_INTEGER is left as a string rather than silently losing precision (framework counters/timestamps stay well within 2^53, so this never bites in practice).
var row = frameworkSchema.coerceRow(driverRow);
typeof row.monotonicCounter; // → "number" (was "1" on Postgres)
Buffer.isBuffer(row.nonce); // → true
b.frameworkSchema.coerceRows(rows) #
Apply coerceRow to every row in an array (in place); returns the array. A non-array argument is returned unchanged.
var rows = frameworkSchema.coerceRows(await queryAll(sql));
b.frameworkSchema.ensureSchema(opts) #
{
externalDbBackend: string, // backend name registered with b.externalDb (required)
dialect: "postgres"|"mysql"|"sqlite", // default: "postgres"
}
Create every framework-owned table + index in the operator's external database, then install append-only WORM triggers on _blamejs_audit_log, _blamejs_consent_log, and _blamejs_audit_checkpoints. Idempotent: every DDL uses IF NOT EXISTS and re-running is safe across reboots.
Returns { tables } with the set of CREATE TABLE names emitted so the operator can confirm the expected surface landed.
Throws FrameworkSchemaError("framework-schema/invalid-config") when externalDbBackend is missing and FrameworkSchemaError("framework-schema/unsupported-dialect") when dialect is anything other than postgres, mysql, or sqlite. On MySQL, a connection without the privilege to create the append-only triggers throws framework-schema/mysql-trigger-privilege rather than leaving the audit tables writable.
Adding a column to an existing table is checked against the declaration: framework-schema/undeclared-additive-column for a column the table does not declare, and framework-schema/invalid-additive-column for one written NOT NULL with no DEFAULT, which no existing row could satisfy.
try {
var report = await b.frameworkSchema.ensureSchema({
externalDbBackend: "primary",
dialect: "postgres",
});
report.tables[0]; // → "_blamejs_audit_log"
} catch (e) {
e.code; // → "framework-schema/unsupported-dialect"
}
b.frameworkSchema.withDeleteTriggersSuspended(opts, fn) #
{
externalDbBackend: string, // the cluster backend name
dialect: string, // mysql | sqlite (required)
}
Run fn with the append-only BEFORE-DELETE triggers dropped from the cluster audit tables, then put them back.
**The purge does not use this.** Every dialect's guard is now bounded by the purge anchor — it stays installed and permits only the rows the anchor already licenses — so a purge never takes it off, and the window this opens is not on the framework's own path. It remains for an operator performing a supervised removal by hand: it grants nothing they could not do with DROP TRIGGER themselves, and it puts back exactly what it took off.
Understand the cost before calling it. A DROP TRIGGER is global, so for as long as fn runs, any other connection holding the framework role can delete audit rows too — the protection is off for the database, not for the caller. The triggers are restored in a finally, so they come back whether fn succeeded or threw, and a table that could not be restored is named in the error. A process killed mid-call leaves them off until the next ensureSchema, which every boot runs.
Postgres is not supported: its guard is a trigger FUNCTION shared by every WORM table, so dropping the triggers and putting them back is not the same operation there. dialect is required — there is no dialect it would be safe to guess for DDL against an operator's audit tables.
await b.frameworkSchema.withDeleteTriggersSuspended(
{ externalDbBackend: "primary", dialect: "mysql" },
async function () { await deleteTheArchivedRange(); });
b.frameworkSchema.wormGuardIsAnchorBounded(dialect) #
Whether this dialect's append-only guard decides per row against the purge anchor's recorded boundary. When it does, a sanctioned deletion needs nothing from the caller: the anchor is written before the rows go, so the guard already permits exactly what the purge will delete and refuses everything else. When it does not, the caller has to take the guard off for the deletion — see withDeleteTriggersSuspended for what that costs.
It answers for the guards ensureSchema installs, which is every guard on a cluster or external-database volume. A single-node b.db volume is NOT in scope: its local SQLite triggers refuse a DELETE outright, and its purge suspends and reinstalls them inside one transaction rather than consulting this. Asking here about that volume would get the answer for a different guard.
All three dialects are bounded, by two mechanisms. Postgres and MySQL express the condition in the trigger body — MySQL's needs IF … THEN SIGNAL …; END IF, whose internal semicolon makes the body compound, so a protocol driver sends it as one message and only a CLI needs DELIMITER to keep from splitting it. SQLite has no such body, so the condition rides in a WHEN clause, where a subquery can read the boundary without the purge ever taking the guard off. A dialect this module installs no guard for is reported unbounded rather than assumed safe.
b.frameworkSchema.wormGuardIsAnchorBounded("postgres"); // → true
b.frameworkSchema.wormGuardIsAnchorBounded("sqlite"); // → true
b.frameworkSchema.wormGuardIsAnchorBounded("oracle"); // → false