brainy/docs/guides/snapshots-and-time-travel.md
David Snelling 5c3bb2c864 feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.

Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
  deferred durability. Wired into add/update/remove/relate/updateRelation/
  unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
  immediately; its before-image is buffered in an in-memory pending tier that
  resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
  synchronous now() freezes with no forced flush. One fsync per window
  (triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
  groupCommit:true): a crash mid-flush discards the partial generation and
  never restores its before-images, which would otherwise revert the
  already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
  baseline: a fresh brain reports generation()===0 and an empty
  transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
  (generation()), so un-flushed single-op writes are overlaid too.

Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
  { maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
  adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
  caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
  reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
  (a coordinator's fair-share input). Per-generation bytes recorded in each
  delta enable historyBytes() introspection without a storage size API.

Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00

14 KiB

title slug public category template order description next
Snapshots & Time Travel guides/snapshots-and-time-travel true guides guide 9 Recipes for the Db API — instant backups with persist(), restore, time-travel debugging with asOf(), range queries over history (diff, history, since, log windows), persist-before-migrate, what-if analysis with with(), and audit trails via transaction metadata.
concepts/consistency-model
guides/optimistic-concurrency

Snapshots & Time Travel

Brainy 8.0 treats the database as a value: brain.now() pins the current state as an immutable Db, brain.transact() commits an atomic batch and hands you the resulting value, brain.asOf() opens past state, and db.persist() cuts a self-contained snapshot. This guide is the recipe book. The precise guarantees behind every recipe live in the consistency model.

Instant backup

Pin the current state, persist it, release:

const db = brain.now()
try {
  await db.persist('/backups/2026-06-11')
} finally {
  await db.release()
}

On filesystem storage the snapshot is built from hard links: every data file in Brainy is immutable-by-rename, so the snapshot is created without copying entity data and shares disk space with the live store. Later writes can never alter it — a rewrite swaps the inode, the snapshot keeps the old bytes. Cross-device targets fall back to per-file byte copies, and persisting an in-memory brain serializes it to the same directory layout — a real, durable store.

Two things to know:

  • persist() requires the view to still be the store's latest generation. If something committed after your pin, it throws GenerationConflictError instead of snapshotting the wrong state — pin and persist before further writes, or retry with a fresh brain.now().
  • The target directory must be empty or absent.

For scheduled backups, this loop is the whole job:

const db = brain.now()
try {
  await db.persist(`/backups/${new Date().toISOString().slice(0, 10)}`)
} finally {
  await db.release()
}

Restore

restore() replaces the store's entire current state from a snapshot — entities, relationships, indexes, history. It is deliberately loud about it:

await brain.restore('/backups/2026-06-11', { confirm: true })
  • { confirm: true } is mandatory — current state is destroyed.
  • The snapshot is copied in (never linked), so it stays independent and can be restored again later.
  • All indexes are rebuilt from the restored records.
  • The generation counter is floored at its pre-restore value, so generation numbers you observed before the restore are never reissued.
  • Live Db pins do not survive a restore — release them first.

Open a snapshot read-only

You do not have to restore to look inside a snapshot. Brainy.load() opens it as a self-contained read-only store with the full query surface, including vector search:

const db = await Brainy.load('/backups/2026-06-11')

const hits = await db.find({ query: 'unpaid invoices from the spring campaign' })
const orders = await db.find({ type: NounType.Document, subtype: 'order' })

await db.release()   // closes the underlying read-only instance

brain.asOf('/backups/2026-06-11') does the same from an existing brain. This is also the 8.0 answer to "named branches": a branch is a name → path mapping your application keeps, where each path is a persisted snapshot. Need a writable copy? Restore the snapshot into a fresh data directory and open a writer on it — instead of switching a shared store between branches in place, every line of code always sees exactly the store it opened.

Time-travel debugging

When production data looks wrong, query the past directly — by wall-clock time or by generation:

// What did this order look like yesterday?
const yesterday = await brain.asOf(new Date(Date.now() - 86_400_000))
const before = await yesterday.get(orderId)

// Full queries work at any reachable generation — search, graph, filters:
const thenActive = await yesterday.find({
  type: NounType.Document,
  subtype: 'order',
  where: { status: 'active' }
})

await yesterday.release()

Pin two points in time and diff them:

const before = await brain.asOf(1041)
const after = brain.now()

const changed = await after.since(before)
changed.nouns   // entity ids touched by transactions in between
changed.verbs   // relationship ids touched in between

await before.release()
await after.release()

Three things to remember:

  • History granularity is per-write: EVERY write — transact() AND a single-operation add/update/remove/relate — is its own immutable generation, so a pin always freezes against later writes and every write is individually addressable via asOf() (see the consistency model). Use transact() when you want several operations to share ONE atomic generation.
  • The first index-accelerated query (semantic search, traversal, cursors, aggregation) at a historical generation builds an in-memory index materialization — O(n at that generation), once per Db, freed on release(). Metadata-level reads are free.
  • Generations reclaimed by compactHistory() throw GenerationCompactedError — persist anything you need to keep forever.

Range queries over history

asOf() answers "what was the state AT a point". Four range verbs answer "what happened BETWEEN two points" and "what is one entity's whole history". They all build on the same generation records — no extra bookkeeping.

diff(a, b) — what changed, classified

since() gives you the raw set of touched ids. diff() goes further: it resolves each touched id at both endpoints and classifies it as added, removed, or modified — split by entities (nouns) and relationships (verbs). An id that was touched but ended up identical (changed then reverted, or created and deleted within the interval) lands in none of the buckets. Endpoints are a generation, a Date, or a Db, in either order:

const d = await brain.diff(1041, brain.generation())

d.added.nouns      // entity ids created between the two states
d.removed.nouns    // entity ids deleted
d.modified.nouns   // entity ids whose stored value actually changed
d.added.verbs      // …relationships, the same three ways

Orientation is a → b: added means "exists at b, not at a". The comparison behind modified is key-order-insensitive, so a no-op re-write of the same fields never shows up as a change.

history(id, range?) — one entity, every version

asOf() is per-generation; history() is per-entity. It returns every distinct version of one id over a range, oldest first — each value is the materialized state at that version (and null marks a removal):

const h = await brain.history(invoiceId)

for (const v of h.versions) {
  console.log(v.generation, v.value?.metadata?.status ?? '(deleted)')
}
// 1041 'draft'
// 1043 'approved'
// 1050 'paid'

Every version ties to the trusted asOf() path — v.value equals (await brain.asOf(v.generation)).get(id). Pass { from, to } (generation or Date) to bound the range; a from below the compaction horizon is quietly truncated to it rather than throwing (history is best-effort over surviving records).

since() and transactionLog() take ranges too

since() accepts a Db, a generation number, or a Date — all equivalent, all an exclusive lower bound (db.since(prior) equals db.since(prior.generation)):

await brain.now().since(1041)                          // ids changed after generation 1041
await brain.now().since(new Date(Date.now() - 3_600_000)) // …in the last hour

transactionLog({ from, to }) windows the commit log inclusively on both ends (a log window names the commits it spans — the deliberate contrast to since's exclusive lower bound); limit applies after the window, newest first:

const window = await brain.transactionLog({ from: 1041, to: 1050 })  // commits 1041…1050
const recent = await brain.transactionLog({ from: lastHour, limit: 20 })

Composing them

"Which orders changed in this window?" is diff ids intersected with an asOf query — the two agree by construction:

const changed = await brain.diff(g1, g2)
const atG2 = await brain.asOf(g2)
const changedOrders = (await atG2.find({ type: NounType.Document, subtype: 'order' }))
  .map(r => r.id)
  .filter(id => changed.added.nouns.includes(id) || changed.modified.nouns.includes(id))
await atG2.release()

One contrast to keep straight: diff and since throw GenerationCompactedError for a bound below the horizon, while history truncates to the horizon — diffs must be exact, history is best-effort.

Safe schema migration

brain.migrate() integrates with snapshots directly: pass backupTo and a hard-link snapshot of the current generation is persisted before any transform runs:

const result = await brain.migrate({ backupTo: '/backups/pre-migration-8.0' })
console.log(result.migrationsApplied, result.backupPath)

// If the migration went wrong, roll the whole store back:
await brain.restore('/backups/pre-migration-8.0', { confirm: true })

The same persist-before-mutate pattern works for any risky bulk operation, not just migrations:

const pin = brain.now()
try {
  await pin.persist('/backups/pre-bulk-edit')
} finally {
  await pin.release()
}
await runRiskyBulkEdit(brain)

What-if analysis

db.with(ops) applies a transaction speculatively, in memory — nothing touches disk, the generation counter, or the indexes. Ask "what would the store look like if…", then commit the same operations for real:

const ops = [
  { op: 'update', id: employeeId, metadata: { team: 'platform' } },
  { op: 'relate', from: employeeId, to: milestoneId, type: VerbType.ParticipatesIn, subtype: 'assignment' }
]

const base = brain.now()
const whatIf = await base.with(ops)

await whatIf.get(employeeId)                          // sees the change
await whatIf.find({ where: { team: 'platform' } })    // metadata finds work
await whatIf.related(employeeId)                      // overlay relations included

await whatIf.release()
await base.release()

// Looks right — make it real, atomically:
await brain.transact(ops)

The boundary: speculative entities carry no embeddings (with() never invokes the embedder), so semantic search, traversal, cursors, aggregation, and persist() throw SpeculativeOverlayError on overlay views instead of returning silently incomplete results. get(), metadata-filter find(), and filter-based related() are fully supported. Overlays chain — calling with() on an overlay stacks another layer.

Audit trails

transact() reifies transaction metadata: whatever you pass as meta is recorded durably alongside the committed generation and timestamp, readable via brain.transactionLog():

await brain.transact(
  [{ op: 'update', id: invoiceId, metadata: { status: 'approved' } }],
  { meta: { author: 'approvals-service', actor: 'jane@example.com', reason: 'PO-7741' } }
)

const log = await brain.transactionLog({ limit: 20 })   // newest first
// [{ generation: 1042, timestamp: 1765432100000, meta: { author: 'approvals-service', ... } }]

Combine the log with asOf() to reconstruct exactly what any transaction did:

const [entry] = await brain.transactionLog({ limit: 1 })

const after = await brain.asOf(entry.generation)
const before = await brain.asOf(entry.generation - 1)

const touched = await after.since(before)
for (const id of touched.nouns) {
  console.log(id, await before.get(id), '→', await after.get(id))
}

await before.release()
await after.release()

For per-entity write coordination (rather than whole-store history), the _rev counter and ifRev CAS remain the right tool — see optimistic concurrency.

Keeping history bounded

Under Model-B every write is a generation, so history can grow quickly — Brainy auto-compacts on every flush()/close() under the retention knob (configured on the constructor):

// Zero-config: ADAPTIVE — keep as much history as free disk/RAM allows,
// reclaiming oldest-first under pressure. (This is the default.)
new Brainy({ /* retention unset */ })

// Unbounded — never reclaim history (opt in explicitly):
new Brainy({ retention: 'all' })

// Explicit CAPS — reclaim oldest-unpinned generations while ANY cap is exceeded:
new Brainy({ retention: { maxGenerations: 1000, maxAge: 7 * 86_400_000, maxBytes: 512 * 1024 ** 2 } })

Reclaim manually at any time (the same caps):

await brain.compactHistory({ maxGenerations: 100, maxAge: 7 * 24 * 60 * 60 * 1000 })

Compaction never breaks a pinned read — record-sets are reclaimed only when no live Db could need them (live pins are ALWAYS exempt). Release views you are done with (including the ones transact() returns), and persist() any generation you want to keep beyond the retention window: snapshots are self-contained and unaffected by compaction.

From branches to values

If you used the pre-8.0 fork/checkout/commit/versions surface, every use case maps to a sharper tool:

Pre-8.0 habit 8.0 recipe
fork() to experiment safely db.with(ops) for speculation in memory; a restored snapshot in a fresh directory for a long-lived writable copy
commit() checkpoints transact(ops, { meta }) — every batch is an atomic, logged, time-travelable commit
checkout() to switch branches Open the snapshot you want — Brainy.load(path) read-only, or restore into its own directory. No in-place switching: every handle always sees one unambiguous store.
getHistory() brain.transactionLog() + db.since(priorDb)
versions.save() per-entity snapshots A pinned Db or persisted snapshot captures every entity at that moment; asOf() reads any entity's past state
versions.restore() brain.restore(snapshot, { confirm: true }) for the whole store, or read the old entity via asOf() and write it back with transact()
Backup branches db.persist(path) — instant, hard-link-shared, self-contained