history
| Subpath | @noy-db/hub/history |
| Factory | withHistory() |
| Layer | with-commit |
| Cluster | B — 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 enableshistory(),getVersion(),diff(),revert(), andvault.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 ofenabled. No effect whenwithHistory()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):
| configuration | relative cost |
|---|---|
| snapshots only | 1.00× |
| ledger only | 2.35× |
| both | 2.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.
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())
| Method | Returns | Purpose |
|---|---|---|
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())
| Method | Returns | Purpose |
|---|---|---|
vault.ledger() | LedgerStore | The vault's hash-chained audit log. Read via .entries({ from?, to? }), .head(), .verify(); throws if withHistory() isn't opted in. |
vault.at(timestamp) | VaultInstant | A 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 ashistorytocreateNoydb()(vault-wide) orhistoryConfigtovault.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()wrapsdiff()).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'sprevHashchain.envelopePayloadHash(envelope)— hashes an encrypted envelope's ciphertext the same way the ledger does, for cross-checking a record against itspayloadHash.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/deletesucceed but no snapshot or ledger entry is writtencollection.history(),getVersion(),diff()throw with a pointer to@noy-db/hub/historyvault.ledger(),vault.at()throwvault.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 tovault.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.casAtomicistrue(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; throwsLedgerContentionErroron exhaustion. Stores withcasAtomic: false(the built-into-filestore 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
nulleven 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 whatverifyBackupIntegrity()adds.
See also
- Vaults & collections — the vault/collection model this service extends
- bundle —
.noydbbackups embed the ledger head; loading one runs the same integrity check asverifyBackupIntegrity() - periods · shadow — related services built on this one
- Services overview · Orientation map
- Proven by showcases
07-with-historyand17-with-history-time-machine.