- New public guide (guides/external-backups): the sparse-mmap reality for external backup tooling — why a brain directory can show 100+ GB apparent size on a small disk, which files are sparse, tar czSf / rsync --sparse / cp --sparse=always, live-store caveats, and what persist()/restore() already handle natively. - New public concept (concepts/generation-fact-log): what facts are (after-image commit records, body-less tombstones), the crash-safety model (checksummed frames, reconcile-to-committed, absent = never committed), the scanFacts()/factSegmentPaths() surfaces with the telemetry shape, family stamps + open-time coherence, and the storage capability seam for plugin authors. - Cross-link from the snapshots guide; sparse notes added to the public persist()/restore() JSDoc (the operator-facing sites).
117 lines
5.5 KiB
Markdown
117 lines
5.5 KiB
Markdown
---
|
|
title: The Generation Fact Log
|
|
slug: concepts/generation-fact-log
|
|
public: true
|
|
category: concepts
|
|
template: concept
|
|
order: 6
|
|
description: Every committed write also appends a self-verifying "fact" — an after-image commit record — to an append-only log. What facts are, the crash-safety model, the scanFacts() streaming surface, family stamps, and how index providers consume the log for sequential heals.
|
|
next:
|
|
- concepts/consistency-model
|
|
- guides/snapshots-and-time-travel
|
|
---
|
|
|
|
# The Generation Fact Log
|
|
|
|
Since 8.4.0, every committed generation also appends a **fact** — a compact record of what each
|
|
touched entity or relationship *became* — to an append-only, checksummed log under
|
|
`_generations/facts/`. Where the generational history answers *"what did things look like
|
|
before?"* (before-images, powering `asOf()` and rollback), the fact log answers *"what happened,
|
|
in order?"* — one sequential, self-verifying stream of the store's present being written.
|
|
|
|
Nothing about querying changes. The fact log exists for three consumers:
|
|
|
|
1. **Index heals and rebuilds** — one sequential read in commit order replaces a per-entity
|
|
directory walk over millions of files.
|
|
2. **Incremental catch-up** — a derived index that knows which generation it reflects reads *just
|
|
the gap*, instead of rebuilding from scratch.
|
|
3. **Replay and audit tooling** — anything that wants the store's committed timeline as a stream.
|
|
|
|
## What a fact is
|
|
|
|
One fact per committed generation:
|
|
|
|
- **`generation`** and **`timestamp`** — which commit, when.
|
|
- **`ops`** — every write in that commit: `{ kind: 'noun' | 'verb', id, record }` where `record`
|
|
holds the entity's full after-image (both stored legs), or **`null` for a tombstone** — a
|
|
removal carries no body, by design.
|
|
- **`meta`** — the transaction metadata `transact()` was submitted with, when present.
|
|
- **`blobHashes`** — content-blob references, for exact reclamation accounting.
|
|
|
|
Facts accumulate **from the first write after upgrading** — pre-existing history is not
|
|
retroactively converted, and consumers fall back to the enumeration walk when no log exists.
|
|
|
|
## Crash safety, in one paragraph
|
|
|
|
Facts are appended and fsynced **inside the same durability window as the commit itself**, before
|
|
the commit point — so after a crash, the log can only ever be *ahead* of committed truth, never
|
|
behind it with a hole. On open, the store reconciles the log back to the committed watermark:
|
|
torn tails are detected by per-record checksums and cut; whole records beyond the watermark are
|
|
truncated. The invariant every reader can rely on: **an absent generation was never committed; a
|
|
present fact was.** `transact()` facts are durable the moment `transact()` returns; single-op
|
|
facts share the same group-commit flush as the rest of their generation, so a hard kill loses the
|
|
fact and the generation *together* — never a torn state.
|
|
|
|
## Reading the log
|
|
|
|
```typescript
|
|
const scan = brain.scanFacts({ fromGeneration: 1 })
|
|
if (scan) {
|
|
// Telemetry up front — progress bars get a denominator from second zero.
|
|
console.log(scan.headGeneration, scan.segmentCount, scan.approxFactCount)
|
|
|
|
for await (const batch of scan.batches()) {
|
|
// Each batch: { facts, firstGeneration, lastGeneration, factCount, byteSize, segmentId }
|
|
for (const fact of batch.facts) {
|
|
for (const op of fact.ops) {
|
|
if (op.record === null) {
|
|
// a tombstone: op.id was removed in this generation
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(scan.summary()) // { factsYielded, segmentsRead } — the cross-check
|
|
}
|
|
```
|
|
|
|
- `scanFacts()` returns `null` when the store hosts no fact log (older store, or a storage adapter
|
|
without binary append support) — fall back to enumerating entities.
|
|
- Scans run against a **snapshot**: facts appended after the scan opens never bleed in, each fact
|
|
is yielded exactly once, and a detected gap aborts loudly — never a silent skip.
|
|
- `brain.factSegmentPaths()` returns the immutable, *sealed* segment files for zero-copy consumers
|
|
(the append-mutable tail is excluded — read it through `scanFacts()`).
|
|
|
|
## Family stamps: how a projection proves it's current
|
|
|
|
Anything derived from the store — an index, the entity file tree itself — carries a **family
|
|
stamp**: a small JSON record of *which committed generation the projection reflects*
|
|
(`sourceGeneration`) plus the invariants that verify it whole (exact per-file byte sizes for
|
|
bounded families; rollup invariants like entity counts for unbounded ones). At open, coherence is
|
|
a **comparison**, not a walk:
|
|
|
|
- stamp equals the committed watermark and invariants hold → serve;
|
|
- stamp is behind → the projection reads just the gap from the fact log;
|
|
- invariants fail → loud, named divergence — `brain.repairIndex()` rebuilds from canonical and
|
|
re-stamps.
|
|
|
|
The verifier is exported (`verifyFamilyStamp`) so every projection — TypeScript or native — runs
|
|
literally the same check.
|
|
|
|
## For plugin authors: the storage capability
|
|
|
|
Index providers receive the storage adapter, not the brain — so the host wires the log onto it.
|
|
Feature-detect and prefer the stream; fall back to enumeration:
|
|
|
|
```typescript
|
|
const scan = storage.scanFacts?.({ fromGeneration: stamp.sourceGeneration + 1 })
|
|
if (scan) {
|
|
// sequential catch-up from the log
|
|
} else {
|
|
// enumeration walk (older store or adapter)
|
|
}
|
|
const committed = storage.committedGeneration?.() // the watermark stamps compare against
|
|
```
|
|
|
|
Providers must never construct their own reader over the log's files — the open path belongs to
|
|
the single writer (it reconciles the log at open); the capability is the sanctioned seam.
|