feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
/**
|
|
|
|
|
* @module tests/unit/db/fact-log
|
|
|
|
|
* @description The generation fact log in isolation: wire-format round-trip
|
|
|
|
|
* (positional msgpack facts, bin16 uuids, body-less tombstones), crc32c
|
|
|
|
|
* framing with torn-tail detection, open-time truncation to committed truth
|
|
|
|
|
* (the log can only ever be AHEAD after a crash; open cuts it back), rotation
|
|
|
|
|
* with a manifest-first flip, exactly-once scans with the frozen telemetry
|
|
|
|
|
* shape, and the mmap segment handoff excluding the mutable tail.
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
|
|
|
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
|
|
|
|
import {
|
|
|
|
|
FactLog,
|
|
|
|
|
FACTS_PREFIX,
|
|
|
|
|
type CommitFact,
|
|
|
|
|
type FactLogStorage,
|
|
|
|
|
storageSupportsFactLog
|
|
|
|
|
} from '../../../src/db/factLog.js'
|
|
|
|
|
import { crc32c } from '../../../src/utils/crc32c.js'
|
|
|
|
|
|
|
|
|
|
const UUID = (n: number): string =>
|
|
|
|
|
`00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
|
|
|
|
|
|
|
|
|
const fact = (generation: number, overrides?: Partial<CommitFact>): CommitFact => ({
|
|
|
|
|
generation,
|
|
|
|
|
timestamp: 1_700_000_000_000 + generation,
|
|
|
|
|
ops: [
|
|
|
|
|
{
|
|
|
|
|
kind: 'noun',
|
|
|
|
|
id: UUID(generation),
|
|
|
|
|
record: { metadata: { noun: 'document', title: `doc ${generation}` }, vector: { v: [1, 2] } }
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
...overrides
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('crc32c known-answer vectors', () => {
|
|
|
|
|
it('matches the RFC 3720 test vectors', () => {
|
|
|
|
|
expect(crc32c(new TextEncoder().encode('123456789'))).toBe(0xe3069283)
|
|
|
|
|
expect(crc32c(new Uint8Array(32))).toBe(0x8a9136aa)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('fact log — round-trip, framing, reconcile, rotation, scan', () => {
|
|
|
|
|
let storage: FactLogStorage
|
|
|
|
|
let log: FactLog
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
const mem: any = new MemoryStorage()
|
|
|
|
|
await mem.init()
|
|
|
|
|
expect(storageSupportsFactLog(mem)).toBe(true)
|
|
|
|
|
storage = mem
|
|
|
|
|
log = new FactLog(storage)
|
|
|
|
|
await log.open(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('facts round-trip byte-exactly: ops, tombstones, meta, blobHashes', async () => {
|
|
|
|
|
await log.append(fact(1))
|
|
|
|
|
await log.append(
|
|
|
|
|
fact(2, {
|
|
|
|
|
ops: [
|
|
|
|
|
{ kind: 'verb', id: UUID(21), record: { metadata: { verb: 'contains' }, vector: null } },
|
|
|
|
|
{ kind: 'noun', id: UUID(22), record: null } // TOMBSTONE
|
|
|
|
|
],
|
|
|
|
|
meta: { source: 'test' },
|
|
|
|
|
blobHashes: ['abc123', 'abc123'] // multiset — duplicates preserved
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
const scan = log.scanFacts()
|
|
|
|
|
expect(scan.headGeneration).toBe(2)
|
|
|
|
|
const all: CommitFact[] = []
|
|
|
|
|
for await (const batch of scan.batches()) all.push(...batch.facts)
|
|
|
|
|
|
|
|
|
|
expect(all).toHaveLength(2)
|
|
|
|
|
expect(all[0].generation).toBe(1)
|
|
|
|
|
expect(all[0].ops[0].id).toBe(UUID(1))
|
|
|
|
|
expect(all[0].ops[0].record?.metadata).toEqual({ noun: 'document', title: 'doc 1' })
|
|
|
|
|
expect(all[1].ops[0].kind).toBe('verb')
|
|
|
|
|
expect(all[1].ops[1].record).toBeNull() // the tombstone is body-less
|
|
|
|
|
expect(all[1].meta).toEqual({ source: 'test' })
|
|
|
|
|
expect(all[1].blobHashes).toEqual(['abc123', 'abc123'])
|
|
|
|
|
expect(scan.summary().factsYielded).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('appends are monotonic — a replayed/duplicate generation throws', async () => {
|
|
|
|
|
await log.append(fact(5))
|
|
|
|
|
await expect(log.append(fact(5))).rejects.toThrow(/non-monotonic/)
|
|
|
|
|
await expect(log.append(fact(3))).rejects.toThrow(/non-monotonic/)
|
|
|
|
|
await expect(log.append(fact(6))).resolves.toBeUndefined() // gaps are fine (aborted reservations)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a torn tail (partial frame) is detected and ignored — intact prefix survives', async () => {
|
|
|
|
|
await log.append(fact(1))
|
|
|
|
|
await log.append(fact(2))
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
// Simulate a crash mid-append: chop bytes off the tail file.
|
|
|
|
|
const tailPath = `${FACTS_PREFIX}/seg-${'1'.padStart(20, '0')}.bfl`
|
|
|
|
|
const bytes = (await storage.readRawBytes(tailPath))!
|
|
|
|
|
await storage.writeRawBytes(tailPath, bytes.subarray(0, bytes.length - 7))
|
|
|
|
|
|
|
|
|
|
const reopened = new FactLog(storage)
|
|
|
|
|
await reopened.open(2)
|
|
|
|
|
expect(reopened.headGeneration()).toBe(1) // fact 2's frame was torn → gone
|
|
|
|
|
|
|
|
|
|
const all: CommitFact[] = []
|
|
|
|
|
for await (const b of reopened.scanFacts().batches()) all.push(...b.facts)
|
|
|
|
|
expect(all.map((f) => f.generation)).toEqual([1])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('open() truncates facts beyond committed truth (the crash-ahead shape)', async () => {
|
|
|
|
|
await log.append(fact(1))
|
|
|
|
|
await log.append(fact(2))
|
|
|
|
|
await log.append(fact(3))
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
// The store's committed generation is 1 — facts 2..3 never committed.
|
|
|
|
|
const reopened = new FactLog(storage)
|
|
|
|
|
await reopened.open(1)
|
|
|
|
|
expect(reopened.headGeneration()).toBe(1)
|
|
|
|
|
|
|
|
|
|
const all: CommitFact[] = []
|
|
|
|
|
for await (const b of reopened.scanFacts().batches()) all.push(...b.facts)
|
|
|
|
|
expect(all.map((f) => f.generation)).toEqual([1])
|
|
|
|
|
|
|
|
|
|
// And appends continue cleanly from the truncated head.
|
|
|
|
|
await reopened.append(fact(2))
|
|
|
|
|
expect(reopened.headGeneration()).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a cleared store (committed=0) truncates everything', async () => {
|
|
|
|
|
await log.append(fact(1))
|
|
|
|
|
await log.append(fact(2))
|
|
|
|
|
await log.sync()
|
|
|
|
|
const reopened = new FactLog(storage)
|
|
|
|
|
await reopened.open(0)
|
|
|
|
|
expect(reopened.headGeneration()).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('scan honors fromGeneration/toGeneration inclusively and filters kinds', async () => {
|
|
|
|
|
for (let g = 1; g <= 6; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
const scan = log.scanFacts({ fromGeneration: 2, toGeneration: 4 })
|
|
|
|
|
const all: CommitFact[] = []
|
|
|
|
|
for await (const b of scan.batches()) all.push(...b.facts)
|
|
|
|
|
expect(all.map((f) => f.generation)).toEqual([2, 3, 4])
|
|
|
|
|
|
|
|
|
|
const verbsOnly = log.scanFacts({ kinds: ['verb'] })
|
|
|
|
|
for await (const b of verbsOnly.batches()) {
|
|
|
|
|
for (const f of b.facts) expect(f.ops.every((op) => op.kind === 'verb')).toBe(true)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('batch telemetry carries the frozen shape', async () => {
|
|
|
|
|
for (let g = 1; g <= 5; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
const scan = log.scanFacts({ batchSize: 2 })
|
|
|
|
|
expect(scan.approxFactCount).toBe(5)
|
|
|
|
|
const batches = []
|
|
|
|
|
for await (const b of scan.batches()) batches.push(b)
|
|
|
|
|
expect(batches.length).toBe(3)
|
|
|
|
|
expect(batches[0]).toMatchObject({ firstGeneration: 1, lastGeneration: 2, factCount: 2 })
|
|
|
|
|
expect(batches[0].byteSize).toBeGreaterThan(0)
|
|
|
|
|
expect(typeof batches[0].segmentId).toBe('string')
|
|
|
|
|
expect(scan.summary()).toEqual({ factsYielded: 5, segmentsRead: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('survives reopen: head and content come back from disk', async () => {
|
|
|
|
|
for (let g = 1; g <= 3; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
|
|
|
|
|
const reopened = new FactLog(storage)
|
|
|
|
|
await reopened.open(3)
|
|
|
|
|
expect(reopened.headGeneration()).toBe(3)
|
|
|
|
|
const all: CommitFact[] = []
|
|
|
|
|
for await (const b of reopened.scanFacts().batches()) all.push(...b.facts)
|
|
|
|
|
expect(all.map((f) => f.generation)).toEqual([1, 2, 3])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('segmentPaths excludes the mutable tail (mmap handoff = sealed only)', async () => {
|
|
|
|
|
await log.append(fact(1))
|
|
|
|
|
await log.sync()
|
|
|
|
|
expect(log.segmentPaths()).toEqual([]) // only a tail exists — nothing sealed
|
|
|
|
|
})
|
2026-07-19 14:54:36 -07:00
|
|
|
|
|
|
|
|
describe('scanFacts liveness contract (Stage-2 D1)', () => {
|
|
|
|
|
it('a wedged store fails LOUDLY within the first-batch bound — never a silent hang', async () => {
|
|
|
|
|
// Force a sealed segment (tiny rotateBytes) so the scan must READ from
|
|
|
|
|
// storage, then wedge that read: the exact production shape (a
|
|
|
|
|
// backlogged brain whose segment read never returned).
|
|
|
|
|
const mem: any = new MemoryStorage()
|
|
|
|
|
await mem.init()
|
|
|
|
|
const wedgeable = new FactLog(mem, { rotateBytes: 1 })
|
|
|
|
|
await wedgeable.open(0)
|
|
|
|
|
await wedgeable.append(fact(1))
|
|
|
|
|
await wedgeable.append(fact(2)) // second append rotates → seg 1 sealed
|
|
|
|
|
await wedgeable.sync()
|
|
|
|
|
|
|
|
|
|
const realRead = mem.readRawBytes.bind(mem)
|
|
|
|
|
mem.readRawBytes = (p: string) =>
|
|
|
|
|
p.includes('facts/seg-') ? new Promise(() => {}) : realRead(p) // hangs forever
|
|
|
|
|
|
|
|
|
|
const scan = wedgeable.scanFacts({ firstBatchTimeoutMs: 200 })
|
|
|
|
|
const started = Date.now()
|
|
|
|
|
await expect(scan.batches().next()).rejects.toThrow(/no first batch within 200ms/)
|
|
|
|
|
expect(Date.now() - started).toBeLessThan(5_000) // bound held, not a hang
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a healthy scan is unaffected — first batch well inside the bound, all facts delivered', async () => {
|
|
|
|
|
for (let g = 1; g <= 5; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
const scan = log.scanFacts({ batchSize: 2 })
|
|
|
|
|
const all: CommitFact[] = []
|
|
|
|
|
for await (const b of scan.batches()) all.push(...b.facts)
|
|
|
|
|
expect(all.map((f) => f.generation)).toEqual([1, 2, 3, 4, 5])
|
|
|
|
|
expect(scan.summary().factsYielded).toBe(5)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('consumer think-time between pulls never counts against the producer', async () => {
|
|
|
|
|
for (let g = 1; g <= 4; g++) await log.append(fact(g))
|
|
|
|
|
await log.sync()
|
|
|
|
|
// Bound tighter than the consumer's pause: only the FIRST pull is
|
|
|
|
|
// raced, so a slow consumer after batch 1 must not trip the deadline.
|
|
|
|
|
const gen = log.scanFacts({ batchSize: 2, firstBatchTimeoutMs: 150 }).batches()
|
|
|
|
|
const first = await gen.next()
|
|
|
|
|
expect(first.done).toBe(false)
|
|
|
|
|
await new Promise((r) => setTimeout(r, 400)) // dawdle past the bound
|
|
|
|
|
const second = await gen.next()
|
|
|
|
|
expect(second.done).toBe(false)
|
|
|
|
|
expect((await gen.next()).done).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.
- Wire format: positional msgpack facts [generation, timestamp, ops,
meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
32-byte segment header (magic, formatVersion, firstGeneration,
zeroed+verified reserved); length+crc32c frame per fact; zero-padded
segment names so lexicographic order == generation order; JSON
manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
the existing durability window, so a crash can only leave the log
AHEAD of committed truth — open() truncates back (torn tails detected
by CRC). Absent generation = never committed; a scan can never see an
uncommitted fact. transact() facts are durable-on-return; single-op
facts ride the group-commit flush exactly like buffered history. A
fact-append failure fails the write, loudly — a silent gap would be a
lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
telemetry: head/segments/approx up front, per-batch generation range
+ bytes + segment id, loud abort on gaps, summary cross-check) and
brain.factSegmentPaths() (immutable sealed segments for zero-copy
consumers; the mutable tail excluded). Exported types CommitFact,
FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
feature-detected; filesystem + memory adapters implement them; an
adapter without them hosts no fact log. Fact segments are byte-copied
(never hard-linked) into snapshots. The _generations/facts/ namespace
is registered as a protected family (rebuildable: false): no sweeper
or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
|
|
|
})
|