--- 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 --- # 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. 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 `transact()` commits — single-operation writes advance the clock but do not produce historical records (see the [consistency model](../concepts/consistency-model.md)). Use `transact()` for writes you want to travel back through. - 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 Historical records cost disk space. Reclaim what no live pin protects: ```typescript await brain.compactHistory({ retainGenerations: 100, // keep the 100 most recent commits retainMs: 7 * 24 * 60 * 60 * 1000 // and everything from the last 7 days }) ``` Compaction never breaks a pinned read — record-sets are reclaimed only when no live `Db` could need them. 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 |