feat(log): fact-log format v2 codec — record envelope, type registry, genesis, sector seals; fault-injection shim

The two-implementation contract surface as one pure module (no I/O):
segment header v2 (formatVersion 2 + sealSize in the reserved bytes),
per-record [type u8, version u8] envelope killing the unknown-kind
misclassification trap, the 12-type registry (after-images with minted
ints, tombstones, batch.meta, embed.pending/landed, blob.manifest,
projection.note, bootstrap.baseline, log.genesis with id-space width and
TYPED width-mismatch refusal), vectorLeg inline|{sameAsGeneration} with
writer-enforced single-hop, sector-sealed groups with pad frames, torn-tail
discipline, and GOLDEN BYTE VECTORS pinned so a second (native) reader
implementation can conform byte-for-byte. 50 format pins + a
fault-injecting storage wrapper (tear/drop-sync/fail-append) with 13
self-tests. v1 segments remain readable; nothing writes v2 yet — the
live-format cutover is its own commit.
This commit is contained in:
David Snelling 2026-08-10 09:29:06 -07:00
parent 6595309765
commit 3484107462
4 changed files with 2360 additions and 0 deletions

1220
src/db/factLogFormat.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
/**
* @module db/faultInjectionStorage
* @description Deterministic fault injection at the fact log's raw-byte
* storage surface the test harness half of the durability protocol. Wraps
* any adapter exposing the {@link FactLogStorage} primitives (the exact
* surface the fact log appends and syncs through) and injects the three
* crash shapes durability tests must prove against:
*
* - **torn write** ({@link FaultInjectionStorage.tearWriteAtByte}): the next
* append persists only its first N bytes, then reports success the shape
* of power loss after a partially-flushed page. The caller-side "crash" is
* simulated by abandoning in-memory state and reopening from storage.
* - **dropped sync** ({@link FaultInjectionStorage.dropNextSync}): the next
* sync becomes a silent no-op an fsync the device acknowledged into a
* volatile cache and lost.
* - **failed append** ({@link FaultInjectionStorage.failNextAppend}): the next
* append throws {@link FaultInjectedError} without writing a byte EIO or
* a full disk, surfaced to the writer.
*
* Every injected fault is journaled on {@link FaultInjectionStorage.injectedFaults}
* so tests can assert not just the outcome but that the fault actually fired.
* Knobs are one-shot (they disarm on firing) and re-arming overwrites the
* pending shot. All other operations pass through untouched.
*/
import type { FactLogStorage } from './factLog.js'
/** The error a {@link FaultInjectionStorage.failNextAppend} shot throws. */
export class FaultInjectedError extends Error {
/** The operation the fault fired on. */
public readonly operation: 'append'
/** The storage path the operation targeted. */
public readonly path: string
constructor(operation: 'append', path: string) {
super(`fault injection: ${operation} to ${path} failed by test design`)
this.name = 'FaultInjectedError'
this.operation = operation
this.path = path
}
}
/** One journaled fault event — proof the injected fault actually fired. */
export interface InjectedFault {
kind: 'torn-write' | 'dropped-sync' | 'failed-append'
/** The target path (torn-write / failed-append). */
path?: string
/** The paths a dropped sync was asked to make durable. */
paths?: string[]
/** Bytes the caller asked to append (torn-write). */
requestedBytes?: number
/** Bytes actually persisted (torn-write). */
writtenBytes?: number
}
/**
* A {@link FactLogStorage} wrapper that injects deterministic storage faults.
* Construct it around any conforming adapter and hand it wherever a
* FactLogStorage is accepted unarmed, it is a transparent passthrough.
*/
export class FaultInjectionStorage implements FactLogStorage {
private readonly inner: FactLogStorage
/** Pending torn-write byte count, or null when unarmed. */
private tearAtByte: number | null = null
/** Pending dropped-sync shot. */
private dropSyncArmed = false
/** Pending failed-append shot. */
private failAppendArmed = false
/** Journal of every fault that fired, in firing order. */
public readonly injectedFaults: InjectedFault[] = []
constructor(inner: FactLogStorage) {
this.inner = inner
}
/**
* Arm a torn write: the NEXT {@link appendRawBytes} persists only the first
* `n` bytes of its buffer (all of it when `n` exceeds the buffer) and then
* reports success. One-shot.
*/
tearWriteAtByte(n: number): void {
if (!Number.isInteger(n) || n < 0) {
throw new Error(`fault injection: tearWriteAtByte needs a non-negative integer; got ${n}`)
}
this.tearAtByte = n
}
/** Arm a dropped sync: the NEXT {@link syncRawObjects} silently does nothing. One-shot. */
dropNextSync(): void {
this.dropSyncArmed = true
}
/**
* Arm a failed append: the NEXT {@link appendRawBytes} throws
* {@link FaultInjectedError} without writing. One-shot; wins over a
* simultaneously-armed torn write (nothing is written at all).
*/
failNextAppend(): void {
this.failAppendArmed = true
}
/** Append bytes — the injection point for torn writes and failed appends. */
async appendRawBytes(path: string, bytes: Uint8Array): Promise<void> {
if (this.failAppendArmed) {
this.failAppendArmed = false
this.injectedFaults.push({ kind: 'failed-append', path })
throw new FaultInjectedError('append', path)
}
if (this.tearAtByte !== null) {
const writtenBytes = Math.min(this.tearAtByte, bytes.length)
this.tearAtByte = null
this.injectedFaults.push({
kind: 'torn-write',
path,
requestedBytes: bytes.length,
writtenBytes
})
if (writtenBytes > 0) {
await this.inner.appendRawBytes(path, bytes.subarray(0, writtenBytes))
}
return
}
return this.inner.appendRawBytes(path, bytes)
}
/** Make paths durable — the injection point for dropped syncs. */
async syncRawObjects(paths: string[]): Promise<void> {
if (this.dropSyncArmed) {
this.dropSyncArmed = false
this.injectedFaults.push({ kind: 'dropped-sync', paths: [...paths] })
return
}
return this.inner.syncRawObjects(paths)
}
/** Passthrough. */
async readRawBytes(path: string): Promise<Uint8Array | null> {
return this.inner.readRawBytes(path)
}
/** Passthrough. */
async writeRawBytes(path: string, bytes: Uint8Array): Promise<void> {
return this.inner.writeRawBytes(path, bytes)
}
/** Passthrough. */
async rawByteSize(path: string): Promise<number | null> {
return this.inner.rawByteSize(path)
}
/** Passthrough. */
async readRawObject(path: string): Promise<any | null> {
return this.inner.readRawObject(path)
}
/** Passthrough. */
async writeRawObject(path: string, data: any): Promise<void> {
return this.inner.writeRawObject(path, data)
}
/** Passthrough. */
async deleteRawObject(path: string): Promise<void> {
return this.inner.deleteRawObject(path)
}
}