Chain Writer
Race-safe append to a hash-chained log table. Both audit_log and consent_log share the same row shape — take next monotonic counter, read previous row's rowHash, seal the logical row via field-crypto, materialize null entries for every hashable column so canonicalization sees the same key set at write-time and verify-time, compute rowHash over the sealed content, INSERT with prevHash / rowHash / nonce / fencingToken.
The chain-writer extracts that pattern so every consumer gets the same race protection. Each instance owns a per-chain Mutex serializing read-prev → compute-hash → insert (without it, concurrent appends hash against the same prev-tip and fork the chain), plus a Once initializing the in-process counter from MAX(monotonicCounter) on first use.
Writes route through the cluster-storage dispatcher so the same chain definition works on single-node SQLite and on cluster-mode external Postgres. cluster.requireLeader() runs before the mutex; followers reject with NotLeaderError. Table names are restricted to the ALLOWED_CHAIN_TABLES allowlist so a misconfig can't point a writer at a non-chain table and corrupt the chain semantics.
Operators usually don't construct chain-writers directly — b.audit and b.consent each construct one at module load. Direct use is for new chain-backed tables registered in ALLOWED_CHAIN_TABLES.
b.chainWriter.registerTable(table) #
Register a consumer-owned append-only table as chain-writable so b.chainWriter.create({ table }) accepts it. Call once at boot (config time) for each app table carrying the chain columns (monotonicCounter, recordedAt, nonce, prevHash, rowHash — plus fencingToken in cluster mode). The framework chains (audit_log, consent_log) are pre-registered. Throws ChainWriterError (chain-writer/invalid-config) on a non-identifier name; the name is validated against the SQL identifier rules because it is interpolated into the chain SQL. Idempotent. Returns the registered name.
Operator footgun to avoid on a MULTI-chain table (one configured with a chainKey): the per-key writer restarts monotonicCounter at 1 for each key, so a UNIQUE index on monotonicCounter ALONE (the shape the framework audit_log uses for its single chain) will reject the second key's first row. A keyed chain's uniqueness must be the composite (chainKey, monotonicCounter), never monotonicCounter by itself.
b.chainWriter.registerTable("device_event_log");
var writer = b.chainWriter.create({
table: "device_event_log",
chainKey: "deviceId",
columnsForInsert: ["_id", "deviceId", "monotonicCounter", "recordedAt",
"kind", "payload",
"prevHash", "rowHash", "nonce", "fencingToken"],
hashableColumns: ["_id", "deviceId", "monotonicCounter", "recordedAt",
"kind", "payload"],
});
b.chainWriter.create(opts) #
{
table: string, // a registered chain table (audit_log | consent_log | registerTable name)
chainKey: string, // optional partition column — one independent chain per key value
columnsForInsert: string[], // INSERT column order (every name is identifier-validated)
hashableColumns: string[], // columns that participate in the rowHash canonicalization
validateInput: Function, // optional; (logical) -> throws on invalid shape
}
Build a chain-writer bound to a single hash-chained table. Returns { table, chainKey, append, _resetForTest, _getMutexForTest }. append(logical) is the public surface — async, leader-gated, mutex-serialized; on success it returns the logical row decorated with the computed rowHash and prevHash.
A chainKey makes one table hold many independent chains (one per account / device / tenant): tip-read, counter monotonicity, and the append Mutex all scope per key, so concurrent appends to DIFFERENT keys run in parallel while same-key appends serialize. Bind chainKey into hashableColumns so the partition is tamper-evident in the row hash, and key the table's uniqueness constraint on (chainKey, monotonicCounter), never monotonicCounter alone.
var writer = b.chainWriter.create({
table: "audit_log",
columnsForInsert: ["_id", "monotonicCounter", "recordedAt",
"action", "outcome",
"prevHash", "rowHash", "nonce", "fencingToken"],
hashableColumns: ["_id", "monotonicCounter", "recordedAt",
"action", "outcome"],
});
var row = await writer.append({
action: "user.login",
outcome: "success",
});
row.rowHash; // → ""
row.prevHash; // → ""
Last updated 2026-08-08T16:39:49.652Z by seeder.