- 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).
442 lines
17 KiB
Markdown
442 lines
17 KiB
Markdown
---
|
|
title: Snapshots & Time Travel
|
|
slug: guides/snapshots-and-time-travel
|
|
public: true
|
|
category: guides
|
|
template: guide
|
|
order: 9
|
|
description: 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.
|
|
next:
|
|
- concepts/consistency-model
|
|
- guides/optimistic-concurrency
|
|
- guides/external-backups
|
|
---
|
|
|
|
# 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](../concepts/consistency-model.md).
|
|
|
|
## Instant backup
|
|
|
|
Pin the current state, persist it, release:
|
|
|
|
```typescript
|
|
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.
|
|
|
|
> Archiving a brain directory with **external tools** (`tar`, `rsync`, `cp`)?
|
|
> Some index files are sparse and can explode to their apparent size under a
|
|
> naive copy — see [External Backups & Sparse Storage](/docs/guides/external-backups).
|
|
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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](../concepts/consistency-model.md)). 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:
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```typescript
|
|
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)`):
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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()`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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](./optimistic-concurrency.md).
|
|
|
|
## 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):
|
|
|
|
```typescript
|
|
// 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):
|
|
|
|
```typescript
|
|
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.
|
|
|
|
## Time travel for files (the VFS)
|
|
|
|
Since 8.2.0, time travel covers Virtual Filesystem **content**, not just
|
|
entity records. File bytes are retention-protected: a content blob referenced
|
|
by any generation inside the retention window is never reclaimed, so reading
|
|
the past always returns the exact bytes — never a stale field or a
|
|
dangling hash.
|
|
|
|
**`vfs.readFile(path, { asOf })`** takes a generation number or a `Date` and
|
|
returns the file's exact bytes as they stood then. It resolves the path's
|
|
current entity, then materializes its state at the target generation — so it
|
|
answers *"what did the file at this path hold at that point?"* It bypasses
|
|
the content cache; the `encoding` option still applies. Asking about a
|
|
generation before the file existed throws the usual not-found error, and
|
|
asking past the retention window's compaction horizon throws a
|
|
compacted-generation error.
|
|
|
|
**`vfs.history(path)`** returns the file's versions inside the retention
|
|
window, oldest first — one `FileVersion` per generation that wrote the file,
|
|
the newest entry being the current state:
|
|
|
|
```typescript
|
|
// A CMS page evolves…
|
|
await brain.vfs.writeFile('/pages/home.json', '{"title":"Launch"}')
|
|
await brain.vfs.writeFile('/pages/home.json', '{"title":"Launch v2"}')
|
|
await brain.vfs.writeFile('/pages/home.json', '{"title":""}') // bad deploy!
|
|
|
|
// Every version is listed and readable:
|
|
const versions = await brain.vfs.history('/pages/home.json')
|
|
// → [{ generation, timestamp, hash, size, mimeType? }, …] ascending
|
|
|
|
const good = versions[versions.length - 2]
|
|
const bytes = await brain.vfs.readFile('/pages/home.json', {
|
|
asOf: good.generation
|
|
})
|
|
|
|
// Restore = write the old bytes back. This is a NEW write (a new
|
|
// generation) — history is never rewritten, so the bad version stays
|
|
// visible in the audit trail.
|
|
await brain.vfs.writeFile('/pages/home.json', bytes)
|
|
```
|
|
|
|
Two lifecycle consequences worth stating plainly:
|
|
|
|
- **Deleting or overwriting a file no longer frees its bytes immediately.**
|
|
Old content lives until history compaction reclaims the generations that
|
|
reference it — the same `retention` budget that bounds all Model-B history
|
|
(and pinned views are exempt, exactly as above). Size your `retention` for
|
|
the file-version depth you want; `retention: 'all'` keeps every version of
|
|
every file forever.
|
|
- **After `compactHistory()` reclaims a generation, its file versions are
|
|
gone** and their bytes are physically reclaimed. (This also fixed a
|
|
pre-8.2.0 defect where overwritten content was never reclaimed at all — an
|
|
unbounded silent leak.)
|
|
|
|
## 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 |
|