/** * @module db/factLog * @description The generation FACT LOG — an append-only, CRC-framed record of * every committed generation as an AFTER-IMAGE "fact": what each touched * entity/relationship BECAME (or a body-less tombstone when it was removed). * This is the dual-write half of the log-canonical transition: today the * before-image history + canonical tree remain authoritative; the fact log is * appended at the same commit points and reconciled to committed truth at * open, so consumers (index heals, replays, scans) can read one sequential, * self-verifying stream instead of walking the entity tree. * * ## Wire format (frozen; additive-only within a major) * * Fact (msgpack, POSITIONAL array — the segment header's formatVersion * governs the schema): * * fact := [ generation:u64, timestamp:u64, ops, meta|nil, blobHashes|nil ] * op := [ kind:u8 (0=noun, 1=verb), id:bin16 (raw uuid bytes), * record:[metaLeg, vecLeg] | nil ] // nil = TOMBSTONE * * Segment file (`_generations/facts/seg-.bfl`): * * header := magic "BFACTS\0\0" (8B) | formatVersion:u32 LE | * firstGeneration:u64 LE | reserved 12B (ZEROED, verified) * frame := length:u32 LE | crc32c:u32 LE (of payload) | payload * * A fact is never split across segments; a torn tail (length overrun or CRC * mismatch) terminates that segment's scan — everything before it is intact. * Zero-padded names make lexicographic order == generation order. * * ## Invariant * * After {@link FactLog.open}, the log contains EXACTLY the committed prefix: * facts are appended BEFORE the commit point (inside the same durability * window), so a crash can only leave the log AHEAD of committed truth — open * truncates any fact beyond the committed generation. Absent generation = * never committed; present = committed. A scan can never see an uncommitted * fact. * * The manifest (`_generations/facts/manifest.json`, JSON — forensics stay * terminal-readable) is the single source of truth for the segment SET; * rotation flips it atomically (write-new → fsync → rename) BEFORE the new * tail's first byte exists, so no segment file is ever unaccounted for. * * ## Mixed-version logs (the v2 live-write cutover) * * The segment header's `formatVersion` selects the decoder PER SEGMENT: * v1 segments (ops-shaped facts, the format above) stay readable forever and * are NEVER rewritten; a NEW tail segment writes the v2 format * (`src/db/factLogFormat.ts` — record envelope, minted dense ints, genesis, * sector seals) whenever the int minter is installed ({@link FactLog.setIntMinter} — * the brain wires it from the metadata index's id mapper right after init). * A bare `FactLog` with no minter keeps writing v1 (there is no authority * that could reproduce int assignments, and 0 is never written). Cutover * mechanics on an existing v1 log: an EMPTY v1 tail is re-headed to v2 in * place; a non-empty v1 tail is sealed by an immediate rotation and the new * tail is v2. Decoded v2 facts map back to the SAME {@link CommitFact} shape * v1 consumers read (noun/verb ops with `{metadata, vector} | null` records) — * the vector wrapper object is reconstructed from the record's metadata leg * through the reserved-field hydration law (see `commitFactFromV2`). * * V2 tails additionally: write the `log.genesis` record (id-space width 64 + * the brain id, minted once into the manifest's additive `brainId` field) as * the first record of the FIRST fact of a brand-new log, and seal every * `sync()` to the header-declared sector size with pad frames that are * invisible to readers (torn-page defense at group-commit boundaries). */ import { encode as defaultEncode, decode as defaultDecode } from '@msgpack/msgpack' import { crc32c } from '../utils/crc32c.js' import { prodLog } from '../utils/logger.js' import { FACT_LOG_FORMAT_V1, FACT_LOG_FORMAT_V2, DEFAULT_SEAL_SIZE, parseSegmentHeader, encodeSegmentHeaderV2, encodeFactV2, decodeFact as decodeFormatFact, decodeGroupV2, encodePadFrame, minPadFrameBytes, type CommitFactV2, type LogRecord, type EmbedPendingRecord, type EmbedLandedRecord, type BlobManifestRecord, type BootstrapBaselineRecord, type ProjectionNoteRecord } from './factLogFormat.js' import { splitNounMetadataRecord } from '../types/reservedFields.js' import { NounType } from '../types/graphTypes.js' import { v4 as uuidv4 } from '../universal/uuid.js' // Swappable msgpack implementation — defaults to the JS codec; a native // provider (registered via the plugin registry's 'msgpack' key) may replace // it. Byte-compatibility is the contract (positional arrays, bin16 ids). let msgpackEncode: (value: unknown) => Uint8Array = defaultEncode let msgpackDecode: (bytes: Uint8Array) => unknown = defaultDecode /** Replace the msgpack encode/decode implementation at runtime. */ export function setFactCodec(impl: { encode: (value: unknown) => Uint8Array decode: (bytes: Uint8Array) => unknown }): void { msgpackEncode = impl.encode msgpackDecode = impl.decode } /** Storage-root-relative home of the fact log. */ export const FACTS_PREFIX = '_generations/facts' /** The facts manifest path (JSON). */ export const FACTS_MANIFEST_PATH = `${FACTS_PREFIX}/manifest.json` /** * The v1 segment format version — the MANIFEST's formatVersion gate and the * header value of v1 (minter-less) tails. NOT the live-write ceiling: new * tails write `FACT_LOG_FORMAT_V2` (src/db/factLogFormat.ts) whenever the * int minter is installed; both versions are read forever, per segment. */ export const FACTS_FORMAT_VERSION = 1 /** Rotation threshold: seal the tail segment once it exceeds this many bytes. */ const SEGMENT_ROTATE_BYTES = 8 * 1024 * 1024 /** Segment header: magic(8) + formatVersion(4) + firstGeneration(8) + reserved(12). */ const HEADER_BYTES = 32 const MAGIC = new Uint8Array([0x42, 0x46, 0x41, 0x43, 0x54, 0x53, 0x00, 0x00]) // "BFACTS\0\0" /** Frame prefix: length(4) + crc32c(4). */ const FRAME_PREFIX_BYTES = 8 /** One write inside a fact: what the id became (or a tombstone). */ export interface FactOp { kind: 'noun' | 'verb' id: string /** The AFTER-IMAGE legs, or `null` for a tombstone (the id was removed). */ record: { metadata: unknown | null; vector: unknown | null } | null } /** * V2-native records beyond noun/verb ops that a fact may carry through the * ENCODER (types 6/7/8/9/10 of the v2 registry: embed markers, blob * manifests, projection notes, bootstrap baselines). The deferred-embedding * lifecycle PRODUCES types 6/7 today: `embed.pending` rides the deferred * write's own commit fact and `embed.landed` rides the background worker's * landing commit (recovery folds the pair back out of the log at open). The * blob lifecycle remodels onto type 8 in a later leg. */ export type FactMarkerRecord = | EmbedPendingRecord | EmbedLandedRecord | BlobManifestRecord | ProjectionNoteRecord | BootstrapBaselineRecord /** * Mints the dense integer handle for an entity/verb id at fact-append time — * REQUIRED to be reproducible: a rebuilt id mapper must reproduce the same * assignments exactly, so the only legal implementation delegates to the * metadata index's id mapper (`getOrAssign`). Returns a POSITIVE bigint; a * minter that cannot resolve its mapper throws — an int of 0 is never written. */ export type FactIntMinter = (kind: 'noun' | 'verb', id: string) => bigint /** One committed generation, as scanned back out of the log. */ export interface CommitFact { generation: number timestamp: number ops: FactOp[] meta?: Record blobHashes?: string[] /** * V2-native marker records riding this fact (see {@link FactMarkerRecord}). * Optional and additive: absent on every v1 fact and on every fact the * current writers produce; requires a v2 tail to encode. */ records?: FactMarkerRecord[] } /** The telemetry a scan batch carries (frozen shape). */ export interface FactScanBatch { facts: CommitFact[] firstGeneration: number lastGeneration: number factCount: number byteSize: number segmentId: string } /** * Liveness bound on a scan's FIRST batch (Stage-2 co-freeze, D1 contract): * `batches()` must yield its first batch — or fail loudly — within this many * ms of the first pull. A backlogged or damaged store may be SLOW, but it may * never be SILENT: a consumer awaiting the first batch is otherwise * indistinguishable from a wedge (the exact failure shape a production heal * hit against a generations-backlogged brain). */ export const SCANFACTS_FIRST_BATCH_MS = 10_000 /** The telemetry a scan OPEN returns (frozen shape). */ export interface FactScanHandle { headGeneration: number segmentCount: number approxFactCount: number /** * Ordered batches; a detected gap aborts LOUDLY, never a silent skip. * Liveness contract: the FIRST batch resolves or rejects within * {@link SCANFACTS_FIRST_BATCH_MS} of the first pull — never a silent hang. */ batches: () => AsyncGenerator /** Close telemetry — the invariant cross-check, valid after iteration ends. */ summary: () => { factsYielded: number; segmentsRead: number } } /** Manifest entry for a sealed segment. */ interface SegmentEntry { file: string firstGeneration: number lastGeneration: number facts: number bytes: number } /** The facts manifest (JSON on disk). */ interface FactsManifest { formatVersion: number segments: SegmentEntry[] /** The append target. Its true content is established by scanning (crash tolerance). */ tailSegment: string | null updatedAt: string /** * This brain's stable id (additive, v2 cutover): minted as a uuid at the * first v2 tail creation and never changed; the `log.genesis` record * carries it. Absent on logs that have never had a v2 tail. */ brainId?: string } /** The narrow byte-level storage surface the fact log rides. */ export interface FactLogStorage { appendRawBytes(path: string, bytes: Uint8Array): Promise readRawBytes(path: string): Promise writeRawBytes(path: string, bytes: Uint8Array): Promise rawByteSize(path: string): Promise readRawObject(path: string): Promise writeRawObject(path: string, data: any): Promise syncRawObjects(paths: string[]): Promise deleteRawObject(path: string): Promise } /** True when the storage adapter exposes every primitive the fact log needs. */ export function storageSupportsFactLog(storage: unknown): storage is FactLogStorage { const s = storage as Record return ( typeof s.appendRawBytes === 'function' && typeof s.readRawBytes === 'function' && typeof s.writeRawBytes === 'function' && typeof s.rawByteSize === 'function' ) } /** uuid string → 16 raw bytes (bin16 on the wire). */ function uuidToBytes(id: string): Uint8Array { const hex = id.replace(/-/g, '') if (hex.length !== 32) { // Non-uuid ids (legacy/natural keys) ride as UTF-8 with a length prefix // marker impossible for uuids: we refuse instead — the write API has // guaranteed uuid ids since 8.0, so anything else is a corruption signal. throw new Error(`fact log: id is not a uuid: ${id}`) } const bytes = new Uint8Array(16) for (let i = 0; i < 16; i++) { bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) } return bytes } /** 16 raw bytes → canonical lowercase uuid string. */ function bytesToUuid(bytes: Uint8Array): string { let hex = '' for (let i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, '0') return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` } /** Zero-padded segment filename: lexicographic order == generation order. */ function segmentFileName(firstGeneration: number): string { return `seg-${String(firstGeneration).padStart(20, '0')}.bfl` } /** Build a segment header. Reserved bytes are ZEROED (and verified on open). */ function buildHeader(firstGeneration: number): Uint8Array { const header = new Uint8Array(HEADER_BYTES) header.set(MAGIC, 0) const view = new DataView(header.buffer) view.setUint32(8, FACTS_FORMAT_VERSION, true) view.setBigUint64(12, BigInt(firstGeneration), true) // bytes 20..31 stay zero (reserved) return header } /** Encode one fact into a framed record (length + crc32c + msgpack payload). */ function encodeFrame(fact: CommitFact): Uint8Array { const payload = msgpackEncode([ fact.generation, fact.timestamp, fact.ops.map((op) => [ op.kind === 'noun' ? 0 : 1, uuidToBytes(op.id), op.record === null ? null : [op.record.metadata, op.record.vector] ]), fact.meta ?? null, fact.blobHashes && fact.blobHashes.length > 0 ? fact.blobHashes : null ]) const frame = new Uint8Array(FRAME_PREFIX_BYTES + payload.length) const view = new DataView(frame.buffer) view.setUint32(0, payload.length, true) view.setUint32(4, crc32c(payload), true) frame.set(payload, FRAME_PREFIX_BYTES) return frame } /** Decode one msgpack payload back into a CommitFact. */ function decodeFact(payload: Uint8Array): CommitFact { const raw = msgpackDecode(payload) as unknown[] const [generation, timestamp, ops, meta, blobHashes] = raw as [ number, number, Array<[number, Uint8Array, [unknown, unknown] | null]>, Record | null, string[] | null ] return { generation: Number(generation), timestamp: Number(timestamp), ops: ops.map(([kind, idBytes, record]) => ({ kind: kind === 0 ? ('noun' as const) : ('verb' as const), id: bytesToUuid(idBytes), record: record === null ? null : { metadata: record[0] ?? null, vector: record[1] ?? null } })), ...(meta ? { meta } : {}), ...(blobHashes && blobHashes.length > 0 ? { blobHashes } : {}) } } /** * Deep-normalize a decoded v2 JSON position (metadata legs, meta maps, * notes) back to plain-JSON values: the v2 codec decodes msgpack int64/uint64 * as `bigint` (its u64 wire discipline), but canonical records are JSON — a * metadata timestamp like `createdAt: 1786…` must come back as the NUMBER it * was encoded from. Safe-range bigints narrow exactly; anything beyond the * safe-integer range in a JSON position refuses loudly (it cannot have come * from a JSON write). */ function normalizeWireJson(value: unknown): unknown { if (typeof value === 'bigint') { if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < -BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error( `fact log v2: decoded integer ${value} exceeds the JS safe-integer range in a JSON position` ) } return Number(value) } if (Array.isArray(value)) return value.map(normalizeWireJson) if (value && typeof value === 'object' && !(value instanceof Uint8Array)) { const out: Record = {} for (const [k, v] of Object.entries(value)) out[k] = normalizeWireJson(v) return out } return value } /** * JSON-serialization equivalence for a v2 ENCODE-side JSON position: drop * undefined-valued object keys and map undefined array elements to null — * exactly what `JSON.stringify` does when canonical records are persisted. * Commit facts are built from write-cache-WARM objects that may still carry * undefined-valued engine keys (`service: undefined`, …) which the durable * JSON never had; msgpack would preserve them as nil (the v1 capture's known * wart), so the v2 capture — the future storage authority — sanitizes to the * DURABLE truth instead. */ function toJsonSafe(value: unknown): unknown { if (value === undefined) return null if (Array.isArray(value)) return value.map((v) => (v === undefined ? null : toJsonSafe(v))) if (value && typeof value === 'object' && !(value instanceof Uint8Array)) { const out: Record = {} for (const [k, v] of Object.entries(value)) { if (v === undefined) continue out[k] = toJsonSafe(v) } return out } return value } /** Mirror of the storage layer's stored-timestamp normalization, minus its * `Date.now()` fallback (a DECODER must be deterministic — an unreadable * timestamp is omitted, and the divergence surfaces via the oracle). */ function reconstructTimestamp(value: unknown): number | undefined { if (typeof value === 'number' && value > 0) return value if ( value !== null && typeof value === 'object' && typeof (value as { seconds?: unknown }).seconds === 'number' ) { return (value as { seconds: number }).seconds * 1000 } return undefined } /** * Rebuild a noun's canonical VECTOR-FILE wrapper from a v2 after-image — * the read-side of the hydration law. Canonical noun vector files hold the * denormalized enumerable entity (`{id, vector, connections, level, type, * …reserved fields…, metadata}` — the write path's composition); the v2 * record deliberately carries only the ENTITY state (metadata leg + embedding * floats), because connections/level are derived HNSW residue with their own * rebuild paths (empty in every 8.x write) and the denormalized top-level * fields are projections of the metadata leg. This reconstruction applies * the SAME split/hydrate law the storage layer uses * (`splitNounMetadataRecord` — the single source of truth in * src/types/reservedFields.ts; field map mirrors * `BaseStorage.hydrateNounWithMetadata`, undefined keys omitted exactly as * JSON serialization omits them), so in the no-drift case the reconstructed * wrapper digests byte-equal to canonical. A drifted denormalized copy * surfaces as an oracle `state-differs` — named, never silently absorbed. */ export function reconstructNounWrapper( id: string, metadataLeg: unknown, floats: number[] ): Record { const { reserved, custom } = splitNounMetadataRecord( (metadataLeg ?? null) as Record | null ) const wrapper: Record = { id, vector: floats, connections: {}, level: 0, type: (reserved.noun as string) || NounType.Thing } if (reserved.subtype !== undefined) wrapper.subtype = reserved.subtype if (reserved.visibility !== undefined) wrapper.visibility = reserved.visibility const createdAt = reconstructTimestamp(reserved.createdAt) if (createdAt !== undefined) wrapper.createdAt = createdAt const updatedAt = reconstructTimestamp(reserved.updatedAt) if (updatedAt !== undefined) wrapper.updatedAt = updatedAt if (reserved.confidence !== undefined) wrapper.confidence = reserved.confidence if (reserved.weight !== undefined) wrapper.weight = reserved.weight if (reserved.service !== undefined) wrapper.service = reserved.service if (reserved.data !== undefined) wrapper.data = reserved.data if (reserved.createdBy !== undefined) wrapper.createdBy = reserved.createdBy wrapper._rev = typeof reserved._rev === 'number' ? reserved._rev : 1 wrapper.metadata = custom return wrapper } /** Coerce a candidate embedding to `number[]`: plain arrays pass through * (element-checked); numeric typed arrays (the JS HNSW rebuild path stores * `Float32Array` vectors on the memory adapter) widen via `Array.from`. */ function floatsOf(candidate: unknown, context: string): number[] | undefined { if (Array.isArray(candidate)) { for (const el of candidate) { if (typeof el !== 'number') { throw new Error(`fact log v2: ${context} vector carries a non-number element`) } } return candidate as number[] } if (ArrayBuffer.isView(candidate) && !(candidate instanceof DataView)) { return Array.from(candidate as unknown as ArrayLike) } return undefined } /** Extract the embedding float array from a canonical vector value: a bare * float array (or numeric typed array) passes through; a wrapper object * yields its `vector` floats; `null` stays `null`; anything else refuses * loudly. */ function embeddingLegOf(value: unknown, context: string): number[] | null { if (value === null || value === undefined) return null const direct = floatsOf(value, context) if (direct !== undefined) return direct if (typeof value === 'object') { const nested = floatsOf((value as { vector?: unknown }).vector, context) if (nested !== undefined) return nested } throw new Error( `fact log v2: ${context} has a canonical vector record with no float vector — ` + `cannot encode its after-image` ) } /** * Map one decoded v2 fact to the {@link CommitFact} shape every consumer * already reads: noun/verb after-images and tombstones become ops (vector * wrappers reconstructed — see {@link reconstructNounWrapper}); a * `batch.meta` record becomes `meta` when the fact position carries none; * `log.genesis` is log-level metadata (its width was verified at decode) and * is not an op; marker records surface on the additive `records` field so * nothing is silently dropped. Decoded JSON positions are normalized back * from the codec's bigint discipline ({@link normalizeWireJson}). */ function commitFactFromV2(f: CommitFactV2): CommitFact { const ops: FactOp[] = [] const markers: FactMarkerRecord[] = [] let batchMeta: Record | undefined for (const r of f.records) { switch (r.type) { case 'noun.afterImage': { const metadata = normalizeWireJson(r.metadata) ?? null let vector: unknown | null = null if (r.vectorLeg !== null) { if (!Array.isArray(r.vectorLeg)) { throw new Error( `fact log v2: noun.afterImage ${r.id} carries a vector ref — this reader ` + `resolves inline vectors only (refs are a later leg); refusing` ) } vector = reconstructNounWrapper(r.id, metadata, r.vectorLeg) } ops.push({ kind: 'noun', id: r.id, record: { metadata, vector } }) break } case 'noun.tombstone': ops.push({ kind: 'noun', id: r.id, record: null }) break case 'verb.afterImage': { const metadata = normalizeWireJson(r.metadata) ?? null if (r.vectorLeg !== null && !Array.isArray(r.vectorLeg)) { throw new Error( `fact log v2: verb.afterImage ${r.id} carries a vector ref — this reader ` + `resolves inline vectors only (refs are a later leg); refusing` ) } // The canonical verb vector-file wrapper: endpoints + verb name ride // as first-class v2 wire fields precisely so this reconstruction is // exact ({id, vector, connections:{}, verb, sourceId, targetId} — // verbs carry no `level`). const vector: Record = { id: r.id, vector: r.vectorLeg ?? [], connections: {}, verb: r.verb, sourceId: r.sourceId, targetId: r.targetId } ops.push({ kind: 'verb', id: r.id, record: { metadata, vector } }) break } case 'verb.tombstone': ops.push({ kind: 'verb', id: r.id, record: null }) break case 'batch.meta': batchMeta = normalizeWireJson(r.meta) as Record break case 'log.genesis': break // the log's birth certificate — log-level metadata, not an op case 'projection.note': markers.push({ ...r, note: normalizeWireJson(r.note) as Record }) break case 'bootstrap.baseline': markers.push({ ...r, metadata: normalizeWireJson(r.metadata) }) break default: // embed.pending / embed.landed / blob.manifest carry no loose JSON maps. markers.push(r) break } } const meta = f.meta ? (normalizeWireJson(f.meta) as Record) : batchMeta return { generation: f.generation, timestamp: f.timestamp, ops, ...(meta ? { meta } : {}), ...(f.blobHashes && f.blobHashes.length > 0 ? { blobHashes: f.blobHashes } : {}), ...(markers.length > 0 ? { records: markers } : {}) } } /** One intact v2 frame's extent inside a segment (byte-slicing support). */ interface V2FrameExtent { /** Byte offset just past this frame. */ end: number /** The frame's generation (0 for pad filler). */ generation: number /** True when the frame is a pad (invisible filler). */ isPad: boolean } /** Walk a v2 segment's intact frames (torn-tail terminated), returning each * frame's extent — the byte-level view truncation slices against, so kept * frames are never re-encoded (byte-immutability of CRC-covered frames). */ function walkV2Frames(bytes: Uint8Array): V2FrameExtent[] { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const extents: V2FrameExtent[] = [] let offset = HEADER_BYTES while (offset + FRAME_PREFIX_BYTES <= bytes.length) { const length = view.getUint32(offset, true) const expectedCrc = view.getUint32(offset + 4, true) const start = offset + FRAME_PREFIX_BYTES const end = start + length if (end > bytes.length) break // torn tail const payload = bytes.subarray(start, end) if (crc32c(payload) !== expectedCrc) break // torn tail const fact = decodeFormatFact(payload, FACT_LOG_FORMAT_V2, { expectedIdSpaceWidth: 64 }) as CommitFactV2 extents.push({ end, generation: fact.generation, isPad: fact.records.length === 0 }) offset = end } return extents } /** * The byte offset a v2 segment is cut at to keep exactly the facts with * `generation ≤ keepThrough`: the end of the last kept FACT frame (pads * between kept facts sit inside the retained span; pads after the cut are * dropped and re-sealed at the next sync). When nothing is dropped the cut * lands after the last intact frame — trailing pads retained, only a torn * suffix (if any) removed. */ function v2CutOffset(extents: V2FrameExtent[], keepThrough: number): number { let cut = HEADER_BYTES let lastIntactEnd = HEADER_BYTES for (const e of extents) { lastIntactEnd = e.end if (e.isPad) continue if (e.generation <= keepThrough) { cut = e.end } else { return cut // first beyond-keep fact: everything from here (pads included) goes } } return lastIntactEnd } /** * Parse a segment's bytes: verify the header, then walk frames until the end * or a torn tail (length overrun / CRC mismatch), which terminates the walk — * everything before it is intact. The header's formatVersion selects the * decoder: the v1 walk below is byte-identical to the original v1 reader; * v2 segments decode through the reference codec (`decodeGroupV2`, pads * invisible, id-space width verified at 64 — a disagreeing genesis throws * the codec's typed `GenesisWidthMismatchError`). Returns the decoded facts * plus the byte length of the VALID prefix (header + intact frames), which * reconciliation uses to cut a torn tail without re-encoding. */ function parseSegment( file: string, bytes: Uint8Array ): { facts: CommitFact[]; validBytes: number; formatVersion: number; sealSize?: number } { if (bytes.length < HEADER_BYTES) { prodLog.warn(`[FactLog] segment ${file} shorter than its header — treating as empty`) return { facts: [], validBytes: 0, formatVersion: 0 } } let header: { formatVersion: number; sealSize?: number } try { header = parseSegmentHeader(bytes.subarray(0, HEADER_BYTES)) } catch (err) { throw new Error(`fact log: segment ${file}: ${(err as Error).message}`) } if (header.formatVersion === FACT_LOG_FORMAT_V2) { const group = decodeGroupV2(bytes.subarray(HEADER_BYTES), { expectedIdSpaceWidth: 64 }) return { facts: group.facts.map(commitFactFromV2), validBytes: HEADER_BYTES + group.validBytes, formatVersion: FACT_LOG_FORMAT_V2, sealSize: header.sealSize } } // v1 walk — byte-identical to the original reader. const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const facts: CommitFact[] = [] let offset = HEADER_BYTES while (offset + FRAME_PREFIX_BYTES <= bytes.length) { const length = view.getUint32(offset, true) const expectedCrc = view.getUint32(offset + 4, true) const start = offset + FRAME_PREFIX_BYTES const end = start + length if (end > bytes.length) break // torn tail: frame length overruns the file const payload = bytes.subarray(start, end) if (crc32c(payload) !== expectedCrc) break // torn tail: payload CRC mismatch facts.push(decodeFact(payload)) offset = end } return { facts, validBytes: offset, formatVersion: FACT_LOG_FORMAT_V1 } } /** * The generation fact log. One instance per open store; every method assumes * the single-writer discipline the generation store already enforces (calls * arrive under its commit mutex). */ export class FactLog { private readonly storage: FactLogStorage /** Rotation threshold (bytes); tests may lower it to exercise rotation. */ private readonly rotateBytes: number private manifest: FactsManifest = { formatVersion: FACTS_FORMAT_VERSION, segments: [], tailSegment: null, updatedAt: new Date(0).toISOString() } /** Decoded facts of the TAIL segment (bounded by the rotation threshold). */ private tailFacts: CommitFact[] = [] /** Byte size of the tail segment file (valid prefix, pads included — * pads count toward bytes but NEVER toward facts). */ private tailBytes = 0 /** Highest generation in the log (0 = empty). */ private head = 0 /** Segment paths appended since the last sync (the fsync batch). */ private readonly dirtySegments = new Set() /** The TAIL segment's on-disk format version (selects the live encoder). */ private tailVersion: number = FACT_LOG_FORMAT_V1 /** The tail's sector-seal size (v2 tails; from its header on reopen). */ private tailSealSize: number = DEFAULT_SEAL_SIZE /** The v2 int minter (see {@link FactIntMinter}); null = v1 live writes. */ private intMinter: FactIntMinter | null = null constructor(storage: FactLogStorage, options?: { rotateBytes?: number }) { this.storage = storage this.rotateBytes = options?.rotateBytes ?? SEGMENT_ROTATE_BYTES } /** * Install the v2 int minter — the capability gate for v2 LIVE WRITES. * With a minter installed, every NEW tail segment writes the v2 format and * after-image records carry minted dense ints; without one, live writes * stay v1 (no authority could reproduce int assignments, and 0 is never * written). The brain wires this from the metadata index's id mapper right * after the index is ready; an existing v1 tail cuts over on the next * append (empty tail: re-headed in place; non-empty: sealed by rotation). */ setIntMinter(mint: FactIntMinter): void { this.intMinter = mint } /** The highest committed generation the log holds (0 = empty). */ headGeneration(): number { return this.head } /** * True when this log has EVER had a v2 tail — the manifest's `brainId` is * minted at every v2 tail creation seam and never removed (the tail-version * check is a belt-and-braces second signal). Only v2 facts can carry marker * records, so marker folds (e.g. the deferred-embed recovery scan) skip * v1-only logs WHOLESALE on this one cheap check — no segment is read. */ hasV2History(): boolean { return this.manifest.brainId !== undefined || this.tailVersion === FACT_LOG_FORMAT_V2 } /** * Open the log and reconcile it to committed truth: read the manifest, * establish the tail's intact content (torn-tail scan), then TRUNCATE any * fact with `generation > committedGeneration` — those never committed (a * crash between fact-append and the commit point). After open, the log is * exactly the committed prefix. */ /** * Read (without truncating) every intact fact ABOVE a generation — the * log-authority recovery surface: after a crash, facts beyond the * manifest watermark that survived with valid CRCs are ACKED writes in * durable-at-ack mode, and the owner REPLAYS them instead of letting * open() truncate them. Must be called BEFORE open() (it reads the raw * segments directly; the torn tail's invalid suffix is ignored exactly * like open() would). */ /** * STREAMING twin of {@link FactLog.peekFactsAbove} for the recovery fold: * yields facts above the bound one SEGMENT at a time, ascending, without * ever materializing the whole log (a production first-boot fold OOM-class * allocation storm came from exactly that — GBs of decoded after-images in * one array while the process looked hung). Memory is one segment's worth. * Works manifest-direct (safe before {@link FactLog.open}). Ordering is * structural (segments rotate in order; appends are ordered within one) and * ASSERTED — a violation aborts loudly, never a silent misordered replay. */ async *streamFactsAbove(committedGeneration: number): AsyncGenerator { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (!stored || typeof stored !== 'object' || !Array.isArray(stored.segments)) return if (stored.formatVersion !== FACTS_FORMAT_VERSION) return const files = [...stored.segments.map((s) => s.file)] if (stored.tailSegment) files.push(stored.tailSegment) let lastGen = committedGeneration for (const file of files) { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) if (bytes === null) continue const { facts } = parseSegment(file, bytes) const batch: CommitFact[] = [] for (const f of facts) { if (f.generation <= committedGeneration) continue if (f.generation <= lastGen) { throw new Error( `fact log: streamFactsAbove found non-ascending generations ` + `(${f.generation} after ${lastGen} in ${file}) — refusing to replay out of order` ) } lastGen = f.generation batch.push(f) } if (batch.length > 0) yield batch } } async peekFactsAbove(committedGeneration: number): Promise { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (!stored || typeof stored !== 'object' || !Array.isArray(stored.segments)) return [] if (stored.formatVersion !== FACTS_FORMAT_VERSION) return [] const out: CommitFact[] = [] const files = [...stored.segments.map((s) => s.file)] if (stored.tailSegment) files.push(stored.tailSegment) for (const file of files) { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) if (bytes === null) continue const { facts } = parseSegment(file, bytes) for (const f of facts) { if (f.generation > committedGeneration) out.push(f) } } out.sort((a, b) => a.generation - b.generation) return out } async open(committedGeneration: number): Promise { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (stored && typeof stored === 'object' && Array.isArray(stored.segments)) { if (stored.formatVersion !== FACTS_FORMAT_VERSION) { throw new Error( `fact log: manifest formatVersion ${stored.formatVersion}; this build reads ${FACTS_FORMAT_VERSION}` ) } this.manifest = stored } // Drop sealed segments that sit ENTIRELY beyond committed truth (a crash // right after a rotation whose facts never committed), newest first. while (this.manifest.segments.length > 0) { const last = this.manifest.segments[this.manifest.segments.length - 1] if (last.firstGeneration > committedGeneration) { prodLog.warn( `[FactLog] dropping sealed segment ${last.file} (generations ${last.firstGeneration}..` + `${last.lastGeneration} never committed)` ) await this.storage.deleteRawObject(`${FACTS_PREFIX}/${last.file}`) this.manifest.segments.pop() await this.persistManifest() } else if (last.lastGeneration > committedGeneration) { // A sealed segment STRADDLING committed truth: cut it back. await this.truncateSegmentTo(last.file, committedGeneration) const cut = await this.reloadSegmentEntry(last.file) this.manifest.segments[this.manifest.segments.length - 1] = cut await this.persistManifest() break } else { break } } // Establish the tail: scan its intact prefix, then truncate beyond // committed truth (the common crash shape: buffered single-op facts whose // counter never went durable). if (this.manifest.tailSegment) { const tailPath = `${FACTS_PREFIX}/${this.manifest.tailSegment}` const bytes = await this.storage.readRawBytes(tailPath) if (bytes === null) { // Manifest named a tail whose first byte never landed — an empty // tail. Its header (and format version) is established at the next // append (see the tail-provisioning ladder there). this.tailFacts = [] this.tailBytes = 0 } else { const parsed = parseSegment(this.manifest.tailSegment, bytes) const { facts, validBytes } = parsed this.tailVersion = parsed.formatVersion === FACT_LOG_FORMAT_V2 ? FACT_LOG_FORMAT_V2 : FACT_LOG_FORMAT_V1 this.tailSealSize = parsed.sealSize ?? DEFAULT_SEAL_SIZE const kept = facts.filter((f) => f.generation <= committedGeneration) if (kept.length !== facts.length || validBytes !== bytes.length) { const dropped = facts.length - kept.length if (dropped > 0) { prodLog.warn( `[FactLog] truncating ${dropped} uncommitted fact(s) beyond generation ` + `${committedGeneration} from the tail (never committed)` ) } if (this.tailVersion === FACT_LOG_FORMAT_V2) { // V2: byte-slice at frame boundaries — CRC-covered frames are // byte-immutable; a truncation never re-encodes what it keeps. const cut = v2CutOffset(walkV2Frames(bytes), committedGeneration) await this.storage.writeRawBytes(tailPath, bytes.subarray(0, cut)) this.tailFacts = kept this.tailBytes = cut } else { await this.rewriteTail(kept) } } else { this.tailFacts = facts this.tailBytes = validBytes } } } this.head = this.computeHead() } /** * Append one committed generation's fact. NOT durable until {@link sync} — * the caller batches durability at its commit barrier (transact syncs in * the same call; Model-B group-commit syncs at flush). * * Tail provisioning (in order): a missing tail starts one; a named tail * whose header never landed (manifest-first crash) gets its header now; an * existing V1 tail cuts over to v2 once the minter is installed (empty: * re-headed in place, non-empty: sealed by rotation — v1 segments are never * rewritten); a full tail rotates. The frame then encodes in the TAIL's * format: v2 tails carry after-image records with minted ints (and the * genesis record on the very first fact of a brand-new log); v1 tails keep * the v1 wire format byte-identically. */ async append(fact: CommitFact): Promise { if (fact.generation <= this.head) { throw new Error( `fact log: non-monotonic append (generation ${fact.generation} ≤ head ${this.head})` ) } if (this.manifest.tailSegment === null) { await this.startTail(fact.generation) } else if (this.tailBytes === 0) { await this.reinitializeTailHeader() } else if (this.intMinter !== null && this.tailVersion === FACT_LOG_FORMAT_V1) { if (this.tailFacts.length === 0 && this.tailBytes <= HEADER_BYTES) { await this.upgradeEmptyTailToV2() } else { await this.rotate(fact.generation) } } else if (this.tailBytes >= this.rotateBytes) { await this.rotate(fact.generation) } let frame: Uint8Array if (this.tailVersion === FACT_LOG_FORMAT_V2) { const records = this.buildV2Records(fact) if (this.needsGenesis()) { if (this.ensureBrainId()) await this.persistManifest() records.unshift(this.genesisRecord()) } frame = encodeFactV2({ generation: fact.generation, timestamp: fact.timestamp, records, ...(fact.meta ? { meta: toJsonSafe(fact.meta) as Record } : {}), ...(fact.blobHashes && fact.blobHashes.length > 0 ? { blobHashes: fact.blobHashes } : {}) }) } else { if (fact.records && fact.records.length > 0) { throw new Error( `fact log: marker records (${fact.records.map((r) => r.type).join(', ')}) require a ` + `v2 tail segment — this log's tail is v1 (no int minter installed); refusing rather ` + `than silently dropping them` ) } frame = encodeFrame(fact) } const tailPath = `${FACTS_PREFIX}/${this.manifest.tailSegment}` await this.storage.appendRawBytes(tailPath, frame) this.tailFacts.push(fact) this.tailBytes += frame.length this.head = fact.generation this.dirtySegments.add(tailPath) } /** * Fsync every segment appended since the last sync. SEALS AT SYNC: a v2 * tail is first padded to its sector-seal boundary (one pad frame, * invisible to readers; a gap smaller than the smallest constructible pad * frame pads through one extra sector — the codec's rule), so every * durability barrier leaves the tail sector-aligned: a torn page can only * tear INSIDE the group being written, never a previously-sealed one. */ async sync(): Promise { await this.padTailToSealBoundary() if (this.dirtySegments.size === 0) return const paths = [...this.dirtySegments] this.dirtySegments.clear() await this.storage.syncRawObjects(paths) } // --- GROUP COMMIT ON THE LOG (durable-at-ack mode) ------------------------ // Classic group commit: concurrent writers append, then join ONE fsync // whose completion releases every covered ack. Two slots — the running // sync and at most one queued behind it — give the covering guarantee: // an append followed by ensureSynced() is always covered, because the // sync it awaits STARTS after the append landed (a running sync that // may have snapshotted earlier is never joined; the queued one is). private syncRunning: Promise | null = null private syncQueued: Promise | null = null /** * Await a sync that covers every byte appended before this call. Many * concurrent callers share one fsync (solo caller = immediate sync). The * durability contract of an acked write in log-durable mode: this promise * resolving means the caller's frames survive power loss. */ async ensureSynced(): Promise { if (this.syncQueued) { // A sync that has NOT started yet exists — it will snapshot after our // append, so it covers us. return this.syncQueued } if (this.syncRunning) { // The running sync may have snapshotted before our append — queue the // next one behind it and join that. const queued = this.syncRunning .catch(() => {}) .then(() => { // Promote: the queued sync becomes the running one. this.syncQueued = null this.syncRunning = this.sync().finally(() => { this.syncRunning = null }) return this.syncRunning }) this.syncQueued = queued return queued } this.syncRunning = this.sync().finally(() => { this.syncRunning = null }) return this.syncRunning } /** * Open a scan over committed facts. The scan runs against a MANIFEST * SNAPSHOT (sealed segments + the tail's decoded facts at open) — exactly- * once per fact, inclusive bounds, stable under concurrent appends. Gaps * abort LOUDLY: a missing generation inside a segment's declared range is * corruption, never silently skipped. */ scanFacts(options?: { fromGeneration?: number toGeneration?: number kinds?: Array<'noun' | 'verb'> batchSize?: number /** Test override for the first-batch liveness bound (default {@link SCANFACTS_FIRST_BATCH_MS}). */ firstBatchTimeoutMs?: number }): FactScanHandle { const from = options?.fromGeneration ?? 1 const to = options?.toGeneration ?? this.head const kinds = options?.kinds const batchSize = Math.max(1, options?.batchSize ?? 256) // Snapshot: the segment list + tail content as of NOW. const segments = this.manifest.segments.filter( (s) => s.lastGeneration >= from && s.firstGeneration <= to ) const tailSnapshot = this.tailFacts.filter((f) => f.generation >= from && f.generation <= to) const tailId = this.manifest.tailSegment ?? 'tail' const approxFactCount = segments.reduce((sum, s) => sum + s.facts, 0) + tailSnapshot.length let factsYielded = 0 let segmentsRead = 0 const storage = this.storage async function* batches(this: void): AsyncGenerator { let expectedNext = 0 // gap detection: generations are monotonic, not necessarily dense const emit = (facts: CommitFact[], segmentId: string, byteSize: number): FactScanBatch => ({ facts, firstGeneration: facts[0].generation, lastGeneration: facts[facts.length - 1].generation, factCount: facts.length, byteSize, segmentId }) const filterOps = (fact: CommitFact): CommitFact => kinds ? { ...fact, ops: fact.ops.filter((op) => kinds.includes(op.kind)) } : fact for (const entry of segments) { const bytes = await storage.readRawBytes(`${FACTS_PREFIX}/${entry.file}`) if (bytes === null) { throw new Error( `fact log: sealed segment ${entry.file} is MISSING — the log is damaged; aborting scan` ) } const { facts } = parseSegment(entry.file, bytes) segmentsRead++ const inRange = facts.filter((f) => f.generation >= from && f.generation <= to) for (const f of inRange) { if (f.generation <= expectedNext - 1) { throw new Error(`fact log: out-of-order fact ${f.generation} in ${entry.file} — aborting scan`) } expectedNext = f.generation + 1 } for (let i = 0; i < inRange.length; i += batchSize) { const slice = inRange.slice(i, i + batchSize).map(filterOps) if (slice.length === 0) continue factsYielded += slice.length yield emit(slice, entry.file, slice.reduce((n, f) => n + encodeFrame(f).length, 0)) } } if (tailSnapshot.length > 0) { segmentsRead++ for (const f of tailSnapshot) { if (f.generation <= expectedNext - 1) { throw new Error(`fact log: out-of-order fact ${f.generation} in the tail — aborting scan`) } expectedNext = f.generation + 1 } for (let i = 0; i < tailSnapshot.length; i += batchSize) { const slice = tailSnapshot.slice(i, i + batchSize).map(filterOps) factsYielded += slice.length yield emit(slice, tailId, slice.reduce((n, f) => n + encodeFrame(f).length, 0)) } } } // Liveness wrapper: the FIRST pull races the contract deadline. Only the // first — the bound is time-to-first-batch (proof the producer is alive), // not per-batch pacing; and it runs only while a pull is actually pending, // so consumer think-time between pulls never counts against the producer. const firstBatchTimeoutMs = options?.firstBatchTimeoutMs ?? SCANFACTS_FIRST_BATCH_MS async function* batchesWithLiveness(this: void): AsyncGenerator { const inner = batches() let timer: NodeJS.Timeout | undefined try { const deadline = new Promise((_, reject) => { timer = setTimeout( () => reject( new Error( `fact log: scanFacts produced no first batch within ${firstBatchTimeoutMs}ms ` + `(liveness contract) — the store is wedged or unreadably slow; aborting scan LOUDLY ` + `instead of hanging the consumer.` ) ), firstBatchTimeoutMs ) timer.unref?.() }) const first = await Promise.race([inner.next(), deadline]) if (first.done) return yield first.value } finally { clearTimeout(timer) } yield* inner } return { headGeneration: this.head, segmentCount: segments.length + (tailSnapshot.length > 0 ? 1 : 0), approxFactCount, batches: batchesWithLiveness, summary: () => ({ factsYielded, segmentsRead }) } } /** * The mmap fast path (capability handoff): the immutable sealed segment * files covering `fromGeneration`, in order. The TAIL is deliberately NOT * included — it is append-mutable; consumers read it via {@link scanFacts}. */ segmentPaths(options?: { fromGeneration?: number }): string[] { const from = options?.fromGeneration ?? 1 return this.manifest.segments .filter((s) => s.lastGeneration >= from) .map((s) => `${FACTS_PREFIX}/${s.file}`) } /** * Drop every fact with `generation > keepThrough` — the in-session abort * compensation: a transact appends its fact BEFORE the commit point, so a * real (non-crash) abort after the append must take the fact back out. The * dropped facts can only live in the TAIL (they were just appended); the * rewrite is atomic and bounded by the rotation threshold. */ async dropAbove(keepThrough: number): Promise { if (this.head <= keepThrough) return const kept = this.tailFacts.filter((f) => f.generation <= keepThrough) if (kept.length === this.tailFacts.length) { throw new Error( `fact log: dropAbove(${keepThrough}) found no droppable facts in the tail ` + `(head ${this.head}) — the fact to drop was already sealed; the log needs reopen` ) } if (this.tailVersion === FACT_LOG_FORMAT_V2) { // V2: byte-slice at frame boundaries (kept frames stay byte-identical; // pads between kept facts are retained inside the prefix, trailing pads // go and the next sync re-seals). The dropped frames may be unsynced — // readRawBytes is read-after-write coherent over the append path. const file = this.manifest.tailSegment if (!file) return const tailPath = `${FACTS_PREFIX}/${file}` const bytes = await this.storage.readRawBytes(tailPath) if (bytes === null) { throw new Error(`fact log: dropAbove(${keepThrough}) cannot read the tail segment ${file}`) } const cut = v2CutOffset(walkV2Frames(bytes), keepThrough) await this.storage.writeRawBytes(tailPath, bytes.subarray(0, cut)) this.tailFacts = kept this.tailBytes = cut } else { await this.rewriteTail(kept) } this.head = this.computeHead() } // -- internals ------------------------------------------------------------- private computeHead(): number { if (this.tailFacts.length > 0) return this.tailFacts[this.tailFacts.length - 1].generation const sealed = this.manifest.segments if (sealed.length > 0) return sealed[sealed.length - 1].lastGeneration return 0 } /** The header bytes for a NEW tail: v2 whenever the minter is installed. */ private newTailHeader(firstGeneration: number): Uint8Array { return this.intMinter !== null ? encodeSegmentHeaderV2(firstGeneration, DEFAULT_SEAL_SIZE) : buildHeader(firstGeneration) } /** Record the just-created tail's format in memory (mirrors its header). */ private noteFreshTail(): void { this.tailVersion = this.intMinter !== null ? FACT_LOG_FORMAT_V2 : FACT_LOG_FORMAT_V1 this.tailSealSize = DEFAULT_SEAL_SIZE } /** Create the very first tail segment (manifest-first, then header bytes). */ private async startTail(firstGeneration: number): Promise { const file = segmentFileName(firstGeneration) this.manifest.tailSegment = file if (this.intMinter !== null) this.ensureBrainId() await this.persistManifest() await this.storage.appendRawBytes(`${FACTS_PREFIX}/${file}`, this.newTailHeader(firstGeneration)) this.tailFacts = [] this.tailBytes = HEADER_BYTES this.noteFreshTail() } /** * Seal the tail into the manifest and start a new one. Manifest-first: the * flip both seals the old tail AND names the new one atomically, so no * segment file ever exists unaccounted for. The NEW tail's format follows * the minter gate ({@link newTailHeader}) — this is also the v1→v2 cutover * seam for a non-empty v1 tail (sealed as-is, never rewritten). */ private async rotate(nextGeneration: number): Promise { const sealedFile = this.manifest.tailSegment if (!sealedFile) return // Seal what the tail actually holds (sync() also sector-seals a v2 tail). await this.sync() // sealed segments are always fully durable const entry: SegmentEntry = { file: sealedFile, firstGeneration: this.tailFacts[0]?.generation ?? nextGeneration, lastGeneration: this.tailFacts[this.tailFacts.length - 1]?.generation ?? nextGeneration - 1, facts: this.tailFacts.length, bytes: this.tailBytes } const newFile = segmentFileName(nextGeneration) this.manifest.segments.push(entry) this.manifest.tailSegment = newFile if (this.intMinter !== null) this.ensureBrainId() await this.persistManifest() await this.storage.appendRawBytes(`${FACTS_PREFIX}/${newFile}`, this.newTailHeader(nextGeneration)) this.tailFacts = [] this.tailBytes = HEADER_BYTES this.noteFreshTail() } /** * The v1→v2 cutover for an EMPTY v1 tail: re-head it in place (nothing but * the 32-byte header exists, so no v1 frame is ever rewritten). Also the * cheapest cutover shape: brand-new brains whose first tail predates the * minter installation converge here on their first post-install append. */ private async upgradeEmptyTailToV2(): Promise { const file = this.manifest.tailSegment if (!file) return if (this.ensureBrainId()) await this.persistManifest() const first = this.segmentFirstGenerationFromName(file) const path = `${FACTS_PREFIX}/${file}` await this.storage.writeRawBytes(path, encodeSegmentHeaderV2(first, DEFAULT_SEAL_SIZE)) this.tailBytes = HEADER_BYTES this.tailVersion = FACT_LOG_FORMAT_V2 this.tailSealSize = DEFAULT_SEAL_SIZE this.dirtySegments.add(path) } /** * A manifest-named tail whose header never landed (crash between the * manifest flip and the first header byte — previously this appended * frames into a headerless file the next open could not parse): write the * header now, in the CURRENT format gate. */ private async reinitializeTailHeader(): Promise { const file = this.manifest.tailSegment if (!file) return if (this.intMinter !== null && this.ensureBrainId()) await this.persistManifest() const first = this.segmentFirstGenerationFromName(file) const path = `${FACTS_PREFIX}/${file}` await this.storage.writeRawBytes(path, this.newTailHeader(first)) this.tailBytes = HEADER_BYTES this.noteFreshTail() this.dirtySegments.add(path) } /** True when the NEXT appended fact is the first fact of a brand-new v2 * log — the one that must open with the log.genesis record. */ private needsGenesis(): boolean { return ( this.tailVersion === FACT_LOG_FORMAT_V2 && this.manifest.segments.length === 0 && this.tailFacts.length === 0 ) } /** Mint the brain id into the manifest if absent; true when it changed. */ private ensureBrainId(): boolean { if (this.manifest.brainId) return false this.manifest.brainId = uuidv4() return true } /** The log's birth certificate (id-space width 64 — the only width this * writer mints; a reader expecting another width refuses at decode). */ private genesisRecord(): LogRecord { const brainId = this.manifest.brainId if (!brainId) { throw new Error( 'fact log v2: genesis requires a brainId in the facts manifest — invariant violated' ) } return { type: 'log.genesis', idSpaceWidth: 64, brainId, createdAt: Date.now() } } /** * Convert one CommitFact's ops (+ optional marker records) to v2 wire * records, MINTING ints at append time: entity/verb ints come from the * injected minter (the metadata index's id mapper — the one authority a * rebuild reproduces exactly). Verb endpoints and the verb name ride as * first-class wire fields, lifted from the canonical verb vector wrapper. * Every refusal here is loud — an after-image without a mintable int, a * verb without endpoints, or a vector record without floats fails the * WRITE, never writes a 0. */ private buildV2Records(fact: CommitFact): LogRecord[] { const mint = (kind: 'noun' | 'verb', id: string): bigint => { if (this.intMinter === null) { throw new Error( `fact log v2: no int minter is installed — cannot mint the ${kind} int for ${id}; ` + `refusing to write a v2 after-image (an int of 0 is never written)` ) } const minted = this.intMinter(kind, id) // Reserved-root exemption: int 0 is legitimate for exactly one id — // the all-zeros VFS root, minted 0 by construction at genesis on // existing brains. Zero anywhere else is a corrupt mint. const isReservedRoot = minted === 0n && id === '00000000-0000-0000-0000-000000000000' if (typeof minted !== 'bigint' || minted < 0n || (minted === 0n && !isReservedRoot)) { throw new Error( `fact log v2: the int minter returned ${String(minted)} for ${kind} ${id} — ` + `minted ints are positive bigints (int 0 reserved for the VFS root alone); ` + `refusing to write` ) } return minted } const records: LogRecord[] = [] for (const op of fact.ops) { if (op.kind === 'noun') { if (op.record === null) { records.push({ type: 'noun.tombstone', id: op.id }) continue } records.push({ type: 'noun.afterImage', id: op.id, entityInt: mint('noun', op.id), metadata: toJsonSafe(op.record.metadata ?? null), vectorLeg: embeddingLegOf(op.record.vector, `noun ${op.id}`) }) } else { if (op.record === null) { records.push({ type: 'verb.tombstone', id: op.id }) continue } const wrapper = op.record.vector as Record | null const verbName = wrapper?.verb const sourceId = wrapper?.sourceId const targetId = wrapper?.targetId if ( typeof verbName !== 'string' || typeof sourceId !== 'string' || typeof targetId !== 'string' ) { throw new Error( `fact log v2: verb ${op.id} has no canonical endpoints (verb/sourceId/targetId ` + `live in its vector record, which is missing or torn) — refusing to write an ` + `after-image that could not be replayed` ) } const floats = floatsOf(wrapper?.vector, `verb ${op.id}`) ?? [] records.push({ type: 'verb.afterImage', id: op.id, verbInt: mint('verb', op.id), metadata: toJsonSafe(op.record.metadata ?? null), vectorLeg: floats, verb: verbName, sourceId, sourceInt: mint('noun', sourceId), targetId, targetInt: mint('noun', targetId) }) } } for (const marker of fact.records ?? []) records.push(marker) return records } /** * Pad a v2 tail to its next sector-seal boundary with ONE pad frame — * called from {@link sync} so alignment holds at every durability barrier. * Pads count toward {@link tailBytes} but never toward facts (they are * invisible to every reader); a gap smaller than the smallest constructible * pad frame pads through one extra sector (the codec's rule). No-op for v1 * tails, empty tails, and already-aligned tails. */ private async padTailToSealBoundary(): Promise { if (this.tailVersion !== FACT_LOG_FORMAT_V2) return const file = this.manifest.tailSegment if (!file || this.tailBytes <= HEADER_BYTES) return const remainder = this.tailBytes % this.tailSealSize if (remainder === 0) return let padBytes = this.tailSealSize - remainder if (padBytes < minPadFrameBytes()) padBytes += this.tailSealSize const tailPath = `${FACTS_PREFIX}/${file}` await this.storage.appendRawBytes(tailPath, encodePadFrame(padBytes)) this.tailBytes += padBytes this.dirtySegments.add(tailPath) } /** Atomically persist the manifest (write-new → fsync → rename downstream). */ private async persistManifest(): Promise { this.manifest.updatedAt = new Date().toISOString() await this.storage.writeRawObject(FACTS_MANIFEST_PATH, this.manifest) await this.storage.syncRawObjects([FACTS_MANIFEST_PATH]) } /** Rewrite the tail segment to hold exactly `facts` (atomic replace). */ private async rewriteTail(facts: CommitFact[]): Promise { const file = this.manifest.tailSegment if (!file) return const first = facts[0]?.generation ?? this.segmentFirstGenerationFromName(file) const parts: Uint8Array[] = [buildHeader(first)] for (const f of facts) parts.push(encodeFrame(f)) const total = parts.reduce((n, p) => n + p.length, 0) const merged = new Uint8Array(total) let offset = 0 for (const p of parts) { merged.set(p, offset) offset += p.length } await this.storage.writeRawBytes(`${FACTS_PREFIX}/${file}`, merged) this.tailFacts = facts this.tailBytes = total } /** Cut a SEALED segment back to `committedGeneration` (atomic replace). * v2 segments byte-slice at frame boundaries (kept frames — pads * included — are never re-encoded); the v1 re-encode path is unchanged. */ private async truncateSegmentTo(file: string, committedGeneration: number): Promise { const path = `${FACTS_PREFIX}/${file}` const bytes = await this.storage.readRawBytes(path) if (bytes === null) return const { facts, formatVersion } = parseSegment(file, bytes) const kept = facts.filter((f) => f.generation <= committedGeneration) prodLog.warn( `[FactLog] truncating sealed segment ${file} to generation ${committedGeneration} ` + `(${facts.length - kept.length} uncommitted fact(s) dropped)` ) if (formatVersion === FACT_LOG_FORMAT_V2) { const cut = v2CutOffset(walkV2Frames(bytes), committedGeneration) await this.storage.writeRawBytes(path, bytes.subarray(0, cut)) return } const first = kept[0]?.generation ?? this.segmentFirstGenerationFromName(file) const parts: Uint8Array[] = [buildHeader(first)] for (const f of kept) parts.push(encodeFrame(f)) const total = parts.reduce((n, p) => n + p.length, 0) const merged = new Uint8Array(total) let offset = 0 for (const p of parts) { merged.set(p, offset) offset += p.length } await this.storage.writeRawBytes(path, merged) } /** Re-derive a sealed segment's manifest entry from its actual bytes. */ private async reloadSegmentEntry(file: string): Promise { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) const { facts, validBytes } = bytes ? parseSegment(file, bytes) : { facts: [] as CommitFact[], validBytes: 0 } return { file, firstGeneration: facts[0]?.generation ?? this.segmentFirstGenerationFromName(file), lastGeneration: facts[facts.length - 1]?.generation ?? 0, facts: facts.length, bytes: validBytes } } /** Parse the zero-padded firstGeneration back out of a segment filename. */ private segmentFirstGenerationFromName(file: string): number { const match = /^seg-(\d{20})\.bfl$/.exec(file) return match ? Number(match[1]) : 0 } }