snapshots
| Subpath | @noy-db/hub/snapshots |
| Factory | withSnapshots({ store }) |
| Layer | with-fork |
| Cluster | F — Snapshot & Portability |
| LOC cost | ~200 (off-bundle when not opted in) |
What it does
A vault is noy-db's unit of encrypted storage — one logical database made up of
collections of records, each individually encrypted before it ever reaches a
store (see Core 01 — Vault & collections).
The snapshots service adds point-in-time backup and restore for a whole vault:
db.snapshot() writes a checkpoint you can come back to later with db.restoreSnapshot(),
independent of whatever store the vault normally reads and writes through day to day.
Each snapshot is a .noydb bundle — the same portable encrypted container format
used for exports — written to a second, separate NoydbPodStore (any adapter implementing
readBundle / writeBundle / deleteBundle / listBundles, e.g. toDrive() from @noy-db/to-drive
or s3Bundle() from @noy-db/to-aws-s3). Because the bundle already holds ciphertext, the
snapshot destination never sees plaintext, and restoring re-uses the vault's existing
keyring (its wrapped decryption keys) — no credentials need to be re-supplied. Snapshots
are whole-vault only (no per-collection snapshot), and restoring is a plain overwrite, not a
merge — see Limits & gotchas.
Prerequisites
- A bundle-capable store —
withSnapshots({ store })writes checkpoints to aNoydbPodStore(readBundle/writeBundle/listBundles), e.g.s3Bundle()from@noy-db/to-aws-s3ortoDrive()from@noy-db/to-drive. A plain record store cannot receive snapshots.
Opt in
withSnapshots({ store }) designates the bundle store snapshots are written to, and is
passed as snapshotsStrategy to createNoydb:
import { createNoydb } from '@noy-db/hub'
import { withSnapshots } from '@noy-db/hub/snapshots'
import { toMemory } from '@noy-db/to-memory'
import { s3Bundle } from '@noy-db/to-aws-s3'
const db = await createNoydb({
store: toMemory(),
user: 'owner',
secret: 'correct horse battery staple',
snapshotsStrategy: withSnapshots({
store: s3Bundle({ bucket: 'my-backups', prefix: 'noydb', region: 'us-east-1' }),
retention: { keepLast: 10 }, // optional; default keeps every on-demand snapshot
}),
})
await db.openVault('acct')
Any NoydbPodStore works as the snapshot destination — see S3 bundle store
below for the one shipped in the extended to-* family.
Usage
Take a checkpoint
db.snapshot(vaultId, opts?) requires the vault to already be open (openVault()), and
returns the new snapshot's metadata:
const snap = await db.snapshot('acct', { label: 'before-year-close', note: 'FY2026' })
// snap.version → 'acct__snap_000001' (pass this to restoreSnapshot)
// snap.integrity → 'verified'
opts.label is a human-readable name (shown in a snapshot chooser UI); opts.note is a
freeform memo. Both are optional.
List snapshots
db.listSnapshots(vaultId) reads only the sidecar index — it never downloads snapshot
bytes — and returns metadata newest-first:
const snaps = await db.listSnapshots('acct')
[
{
"version": "acct__snap_000001",
"label": "before-year-close",
"note": "FY2026",
"exportedAt": "2026-01-15T10:03:22.000Z",
"exportedBy": "owner",
"size": 4821,
"integrity": "verified"
}
]
Restore
db.restoreSnapshot(vaultId, version) restores the vault in place and runs
verifyBackupIntegrity() automatically. Collection handles obtained before the call are
now stale — re-obtain them afterward:
await db.restoreSnapshot('acct', snap.version)
const invoices = db.vault('acct').collection<Invoice>('invoices') // re-obtain after restore
Throws SnapshotNotFoundError if version doesn't exist in the snapshot store (pruned by
retention, or a typo). Throws BackupCorruptedError or BackupLedgerError if
verifyBackupIntegrity() detects tampering.
Automatic cadence
withSnapshots({ snapshotPolicy }) enables automatic whole-vault snapshots driven by vault
writes, in addition to on-demand db.snapshot(). Automatic snapshots overwrite a single
rolling key (<vaultId>__auto) and are exempt from retention — the cadence timer can never
evict your labeled on-demand checkpoints, and on-demand snapshot() calls preserve the
rolling slot untouched.
const db = await createNoydb({
store,
user: 'owner',
secret: 'correct horse battery staple',
snapshotsStrategy: withSnapshots({
store: snapshotStore,
snapshotPolicy: { mode: 'debounce', debounceMs: 60_000, minIntervalMs: 300_000 },
retention: { keepLast: 10 }, // applies to on-demand checkpoints only
}),
})
mode | Trigger |
|---|---|
'manual' (default) | No timers — db.snapshot() only. |
'debounce' | Fires after debounceMs of write-idle, with a minIntervalMs floor between fires. |
'interval' | Fires on a fixed intervalMs timer. |
onUnload (default true for non-manual modes) flushes a pending auto-snapshot on tab-hide
or process exit. The auto-snapshot appears first in listSnapshots() (flagged auto: true)
and restores like any other: db.restoreSnapshot(vaultId, '<vaultId>__auto'). The cadence is
wired off the instance's post-write hook: a write marks its vault dirty, the scheduler fires
autoSnapshot() per dirty vault, and any error (or an optimistic-concurrency conflict) during
that call is logged, never thrown — the vault stays marked dirty so the next cadence tick
(interval) or the next write (debounce) retries. db.close() tears the scheduler down.
S3 bundle store
@noy-db/to-aws-s3 (from the extended to-* adapter family) ships s3Bundle() — a
NoydbPodStore for whole-vault .noydb blobs, distinct from the per-record toAwsS3() adapter,
suitable as a snapshot destination:
import { s3Bundle } from '@noy-db/to-aws-s3'
const snapshotStore = s3Bundle({ bucket: 'my-backups', prefix: 'noydb', region: 'us-east-1' })
const db = await createNoydb({
store,
user: 'owner',
secret: 'correct horse battery staple',
snapshotsStrategy: withSnapshots({ store: snapshotStore }),
})
Key scheme is {prefix}/{vaultId}.noydb; the version token is the object's S3 ETag. OCC uses
S3 conditional writes (IfMatch on the ETag) — a lost race throws PodVersionConflictError.
listBundles() derives metadata from paginated ListObjectsV2 calls (no per-object GET).
Requires @aws-sdk/client-s3 ≥ 3.696.
API reference
withSnapshots(opts)
Enables the snapshots service. Pass the result as snapshotsStrategy to createNoydb.
| Option | Type | Purpose |
|---|---|---|
store | NoydbPodStore | Where snapshot blobs and the sidecar index are written. Required. |
retention | RetentionPolicy | Pruning for on-demand snapshots. Default: keep all. Never affects the auto-snapshot slot. |
snapshotPolicy | SnapshotPolicy | Automatic-snapshot cadence. Default mode: 'manual' — no timers. |
db.snapshot(vaultId, opts?)
Takes an on-demand checkpoint of the given vault.
opts | Type | Purpose |
|---|---|---|
label | string | Human-readable name, e.g. for a chooser UI. |
note | string | Freeform memo. |
Throws a plain Error if snapshotsStrategy isn't configured, or ValidationError if the
vault isn't open.
db.listSnapshots(vaultId)
Lists snapshots newest-first (auto-snapshot first, if present), from the sidecar index only.
db.restoreSnapshot(vaultId, version)
Restores the vault to a prior snapshot in place. See Restore above for errors.
SnapshotMeta
interface SnapshotMeta {
version: string // lookup key; pass to restoreSnapshot()
label?: string
note?: string
exportedAt: string // ISO 8601
exportedBy: string // the acting user (NoydbOptions.user) at snapshot time
size: number // bytes
integrity: 'verified' | 'legacy-unverifiable'
auto?: true // present only on the rolling auto-snapshot
}
integrity is 'verified' for every snapshot the current engine produces (the bundle embeds
ledger metadata that restoreSnapshot() checks); 'legacy-unverifiable' is reserved for a
future import path that reads pre-existing bundles with no ledger to verify against.
RetentionPolicy
interface RetentionPolicy {
keepLast?: number // keep only the most recent N on-demand snapshots per vault
maxAgeDays?: number // delete on-demand snapshots older than N days
prune?: boolean // false → never delete; delegate expiry to store-level infra. Default true.
}
Applied eagerly after each db.snapshot() call. keepLast and maxAgeDays can be combined.
Set prune: false to use S3 lifecycle rules or similar infra-level expiry instead.
SnapshotPolicy
interface SnapshotPolicy {
mode?: 'manual' | 'debounce' | 'interval' // default 'manual'
debounceMs?: number // idle delay before firing, mode:'debounce'. Default 30_000.
intervalMs?: number // fixed timer period, mode:'interval'. Default 300_000.
minIntervalMs?: number // hard floor between auto-snapshots, any mode. Default 0.
onUnload?: boolean // flush a pending auto-snapshot on tab-hide/exit. Default true for non-manual modes.
label?: string // label applied to auto-snapshots. Default 'auto'.
}
Errors
| Error class | Code | Thrown when |
|---|---|---|
SnapshotNotFoundError | SNAPSHOT_NOT_FOUND | version not in the snapshot store (pruned or invalid). |
BackupCorruptedError | BACKUP_CORRUPTED | An envelope's hash doesn't match the ledger on restore (tamper detected). |
BackupLedgerError | BACKUP_LEDGER | The hash chain, or its head, doesn't verify on restore (tamper detected). |
PodVersionConflictError | BUNDLE_VERSION_CONFLICT | A snapshot or index write lost an optimistic-concurrency race (e.g. S3 IfMatch 412). |
Behavior when NOT opted in
Without snapshotsStrategy: withSnapshots({ store }), db.snapshot(), db.listSnapshots(),
and db.restoreSnapshot() all reject with an Error pointing at the fix:
Snapshots require the snapshot strategy. Import `{ withSnapshots }` from
"@noy-db/hub/snapshots" and pass it to
`createNoydb({ snapshotsStrategy: withSnapshots({ store }) })`.
Limits & gotchas
- Not a replacement for history (per-record, intra-vault point-in-time). Snapshots are external, whole-vault checkpoints.
- Whole-vault only — there is no per-collection snapshot.
- Restore is an overwrite, not a merge.
restoreSnapshot()replaces vault state in place viavault.load(); the app is responsible for checking unsaved state before calling it. - Single-writer safe by design, not by lock. The on-demand snapshot counter
(
__snap_N) is derived from an index read that isn't cross-process-locked, so twosnapshot()calls racing on the same vault can compute the same blob key and the losing write silently overwrites the winning one's bytes. Multi-writer deployments (server workers, etc.) should serializesnapshot()calls at the application layer. retentionnever touches the auto-snapshot slot — it prunes only the on-demandsnapshotspool, so the cadence timer can't evict a labeled checkpoint and a labeled checkpoint can't evict the rolling auto slot.
See also
- Services overview · Orientation map
- bundle — the
.noydbcontainer format snapshots are written in - history — per-record, intra-vault point-in-time, the service snapshots complements rather than replaces
- archive — relocates cold records to a second store instead of checkpointing the whole vault; a different cold-storage tradeoff than a snapshot
- Core 05 — Stores — the store contract;
NoydbPodStoreis its whole-vault counterpart - families/to —
to-*adapters that implementNoydbPodStore - Proven by showcases
93-with-snapshotsand96-snapshots-auto-cadence.