stable

history

Per-record version snapshots, a hash-chained tamper-evident ledger, JSON Patch deltas, point-in-time reads via `vault.at()`, and backup verification.
Subpath@noy-db/hub/history
FactorywithHistory()
Layerwith-commit
ClusterB — Write & Mutate
LOC cost~1,880 (off-bundle when not opted in)

What it does

This service keeps a full audit trail of every write to a vault — noy-db's end-to-end-encrypted document store (see Vaults & collections). Once enabled, every successful put / delete archives a complete encrypted snapshot of the record's previous state and appends an entry to a hash-chained, tamper-evident ledger — a sequential log where each entry embeds the hash of the entry before it, so editing, deleting, or reordering a past entry breaks the chain and is detectable by verify(). On top of that ledger the service adds JSON Patch–based deltas (compact diffs between consecutive versions), typed field-level diffing, one-call revert, and vault.at(timestamp) — a read-only view of the vault as it stood at any past instant. Because it's opt-in, none of the ~1,880 lines behind it enter your application bundle unless you import withHistory.

When you need it

  • Compliance / audit trails — "who changed this record, and when"
  • Undo / revert UX
  • "What did this record look like on date X" — point-in-time reads via vault.at()
  • Tamper detection — vault.verifyBackupIntegrity() walks the ledger and cross-checks it against the stored data
  • Diff visualisation between two versions of a record

Prerequisites

  • None — withHistory() alone enables history(), getVersion(), diff(), revert(), and vault.at() on every collection.

Opt in

import { createNoydb } from '@noy-db/hub'
import { withHistory } from '@noy-db/hub/history'

const db = await createNoydb({
  user: 'firm',
  secret: 'firm-secret-2026',   // omit `store` to use the built-in in-memory store
  historyStrategy: withHistory(),
})
const vault = await db.openVault('books')

Vault-wide retention caps how many snapshots pile up per record:

const db = await createNoydb({
  user: 'firm',
  secret: 'firm-secret-2026',
  historyStrategy: withHistory(),
  history: { maxVersions: 50 },   // oldest snapshot pruned once a record exceeds 50 versions
})

Per-collection scoping

By default withHistory() is all-or-nothing: every collection gets per-record snapshots and appends to the vault-wide tamper ledger. Pass a per-collection historyConfig to vault.collection() to override the vault-wide config for that collection only (wholesale, not merged):

// Filed legal records — full audit (snapshots + tamper ledger). This is the default.
const receipts = vault.collection('receipts')

// High-churn operational collection — no snapshots, no ledger entries.
const scratch = vault.collection('scratch', {
  historyConfig: { enabled: false, ledger: false },
})
  • enabled: false — suppress per-record snapshots for this collection.
  • ledger: false — exclude this collection's writes from the hash-chained tamper ledger. Its puts/deletes leave no ledger entry; the chain stays valid (vault.ledger().verify() still passes) — it simply never receives those entries. Independent of enabled. No effect when withHistory() is not active (there is no ledger).

This lets you confine snapshots + tamper-evidence to the few collections where they carry legal weight, without paying snapshot + ledger-entry-per-write across operational / derived collections vault-wide.

What each half actually costs

The hash chain is the entire cost of history; per-record snapshots are effectively free. Measured upstream on the primitive write path — no guards, MVs or derivations; sequential put(); in-memory store; N = 3000 (noy-db#1248):

configurationrelative cost
snapshots only1.00×
ledger only2.35×
both2.41×

The cause is structural, not an inefficiency to optimize away: an entry's prevHash depends on the current head, so appends are inherently serialized — one head read, one delta encryption and one CAS-put per record operation. That serialization is the tamper-evidence property.

The ratio travels; the absolute numbers do not. Those figures come from an in-memory store and understate the felt cost. On browser IndexedDB the ledger adds a whole extra encrypted store write to a path whose base operation is already milliseconds — treat ~2.4× as the portable number and the in-memory microseconds as a floor.A single human-paced write pays a difference nobody perceives. What pays visibly is a one-click batch — an approve all, a CSV import, a period prefill — and a write that fans out through derivations and materialized views pays once per resulting stored write. That is what ledger: false is for on a high-churn collection.

Usage

Version snapshots, diff, and revert

interface Invoice { amount: number; status: string }
const invoices = vault.collection<Invoice>('invoices')

await invoices.put('inv-1', { amount: 1000, status: 'draft' })   // → version 1
await invoices.put('inv-1', { amount: 2000, status: 'sent' })    // → version 2
await invoices.put('inv-1', { amount: 2000, status: 'paid' })    // → version 3 (current)

const versions = await invoices.history('inv-1')
[
  { "version": 2, "timestamp": "2026-06-01T10:02:00.000Z", "userId": "firm", "record": { "amount": 2000, "status": "sent" } },
  { "version": 1, "timestamp": "2026-06-01T10:01:00.000Z", "userId": "firm", "record": { "amount": 1000, "status": "draft" } }
]

history() never includes the current live version — only prior snapshots, newest first. Load one directly, diff it against the current record, or revert to it:

const v1 = await invoices.getVersion('inv-1', 1)
// → { amount: 1000, status: 'draft' }

const changes = await invoices.diff('inv-1', 1)   // versionB omitted → diffs against the current record
// → [
//     { path: 'amount', type: 'changed', from: 1000, to: 2000 },
//     { path: 'status', type: 'changed', from: 'draft', to: 'paid' }
//   ]

await invoices.revert('inv-1', 1)
// → writes { amount: 1000, status: 'draft' } back as version 4; the current record is v1's content again

The tamper-evident ledger

Every write to a collection with ledger enabled also appends an entry to vault.ledger() — a hash chain where each entry embeds the hash of the entry before it:

const entries = await vault.ledger().entries()
[
  { "index": 0, "prevHash": "", "op": "put", "collection": "invoices", "id": "inv-1", "version": 1, "ts": "2026-06-01T10:01:00.000Z", "actor": "firm", "payloadHash": "3fa2c1…" },
  { "index": 1, "prevHash": "9e1b7a…", "op": "put", "collection": "invoices", "id": "inv-1", "version": 2, "ts": "2026-06-01T10:02:00.000Z", "actor": "firm", "payloadHash": "8bd004…", "deltaHash": "c771ee…" },
  { "index": 2, "prevHash": "51af90…", "op": "put", "collection": "invoices", "id": "inv-1", "version": 3, "ts": "2026-06-01T10:03:00.000Z", "actor": "firm", "payloadHash": "0a55dd…", "deltaHash": "f42b31…" }
]
const result = await vault.ledger().verify()
// → { ok: true, head: '51af90…', length: 3 }

A broken link — an entry edited, reordered, or deleted out from under the chain — flips this to { ok: false, divergedAt, expected, actual }, pinpointing the first entry whose recorded prevHash no longer matches the recomputed hash of its predecessor.

Point-in-time reads

vault.at(timestamp) reconstructs the vault as it stood at a past instant, using the ledger to resolve which put/delete was live at that moment and the _history snapshots to supply the record's content:

const asOf = vault.at('2026-06-01T10:01:30.000Z')
const snapshot = await asOf.collection<Invoice>('invoices').get('inv-1')
// → { amount: 1000, status: 'draft' }   — v1, the state right after the first put

asOf is read-only: every write method on the view it returns throws ReadOnlyAtInstantError.

Retention and backup integrity

pruneRecordHistory and clearHistory delete old snapshots explicitly, on top of any maxVersions cap set at opt-in:

await invoices.pruneRecordHistory('inv-1', { keepVersions: 1 })
// → 1   (older snapshots beyond the 1 most recent were deleted)

await invoices.clearHistory('inv-1')
// → 1   (the remaining snapshot for inv-1 was deleted)

vault.verifyBackupIntegrity() runs the same chain check as ledger().verify(), plus a cross-check that every current record's ciphertext hash still matches what the ledger recorded for it — the check a restore path (e.g. loading a .noydb bundle) should run before trusting the data:

const integrity = await vault.verifyBackupIntegrity()
// → { ok: true, head: '51af90…', length: 3 }

API reference

Collection methods (enabled by withHistory())

MethodReturnsPurpose
collection.history(id, options?)Promise<HistoryEntry<T>[]>List a record's prior versions, newest first. options: { from?, to?, limit? } — ISO date filters plus a max count.
collection.getVersion(id, version)Promise<T | null>Load one specific past version's content.
collection.revert(id, version)Promise<void>Re-put a past version's content as a brand-new version.
collection.diff(id, versionA, versionB?)Promise<DiffEntry[]>Field-level diff between two versions; omit versionB to diff against the current record. versionA: 0 means "before creation."
collection.pruneRecordHistory(id?, options)Promise<number>Delete snapshots by retention rule — { keepVersions? } and/or { beforeDate? }; omit id to prune every record in the collection. Returns the count deleted.
collection.clearHistory(id?)Promise<number>Delete all snapshots for one record, or for the whole collection when id is omitted.

Vault methods (enabled by withHistory())

MethodReturnsPurpose
vault.ledger()LedgerStoreThe vault's hash-chained audit log. Read via .entries({ from?, to? }), .head(), .verify(); throws if withHistory() isn't opted in.
vault.at(timestamp)VaultInstantA read-only view of the vault at a past ISO-8601 timestamp or Date. .collection(name) returns a CollectionInstant with .get(id) / .list(); every write method throws ReadOnlyAtInstantError.
vault.verifyBackupIntegrity()Promise<VerifyBackupResult>Ledger chain check plus a data-envelope cross-check. { ok: true, head, length } on success; { ok: false, kind: 'chain' | 'data', message, ... } pinpointing the divergence otherwise.

Types

  • HistoryEntry<T> — { version: number, timestamp: string, userId: string, record: T }
  • DiffEntry — { path: string, type: 'added' | 'removed' | 'changed', from?: unknown, to?: unknown }
  • HistoryConfig — { enabled?: boolean, maxVersions?: number, ledger?: boolean }, passed as history to createNoydb() (vault-wide) or historyConfig to vault.collection() (per-collection override)

Lower-level exports (subpath barrel)

Most applications only need the methods above; these are exported for tooling that verifies or replays the chain independently:

  • diff(a, b) / formatDiff(entries) — the pure diff engine and a human-readable formatter (collection.diff() wraps diff()).
  • computePatch(from, to) / applyPatch(state, patch) — the JSON Patch algebra the ledger uses to store and replay deltas.
  • canonicalJson(value) / sha256Hex(text) / hashEntry(entry) — the exact hashing recipe behind the ledger's prevHash chain.
  • envelopePayloadHash(envelope) — hashes an encrypted envelope's ciphertext the same way the ledger does, for cross-checking a record against its payloadHash.
  • LedgerEntry, AppendInput, VerifyResult, JsonPatch, JsonPatchOp, ChangeType — supporting types.
  • LEDGER_COLLECTION ('_ledger') / LEDGER_DELTAS_COLLECTION ('_ledger_deltas') — the internal collection names, for tooling that inspects a raw dump.

Behavior when NOT opted in

  • put / delete succeed but no snapshot or ledger entry is written
  • collection.history(), getVersion(), diff() throw with a pointer to @noy-db/hub/history
  • vault.ledger(), vault.at() throw
  • vault.verifyBackupIntegrity() returns { ok: true, head: '', length: 0 } trivially (no chain to diverge from)

Pairs well with

  • periods — closing a period writes a normal ledger entry, chaining period boundaries into the same tamper-evident sequence as record puts/deletes
  • shadow — vault.frame() is the live-state companion to vault.at(timestamp): same read-only contract (writes throw), but it reads the current vault instead of a past instant
  • consent — keeps its own separate per-access audit trail (_consent_audit), independent of this service's hash-chained ledger

Edge cases & limits

  • Ledger writes are multi-writer safe on any store whose capabilities.casAtomic is true (most built-in and cloud/SQL stores — check the store's own docs for its value) via an optimistic-CAS retry loop on the chain head. Up to 8 retries with exponential backoff + jitter; throws LedgerContentionError on exhaustion. Stores with casAtomic: false (the built-in to-file store and a handful of filesystem/remote-fs stores) remain best-effort under contention — pair them with an advisory lock or single-writer discipline.
  • Time-machine accuracy is bounded by retention: pruned versions are unrecoverable, and queries before the oldest retained snapshot return null even for records that existed.
  • verify() detects tampering within the ledger's own chain; it does not by itself detect data collections edited without a matching ledger entry — that cross-check is what verifyBackupIntegrity() adds.

See also