archive
| Subpath | exported from @noy-db/hub root (no dedicated subpath) |
| Factory | withArchive() |
| Layer | with-fork |
What it does
Over years of use, a vault accumulates records you almost never read — closed fiscal years, settled invoices, expired contracts. The archive service moves those records out of your primary store into a second, cheaper cold store, so the working set stays small while nothing is deleted.
In noy-db, every record is stored as an encrypted envelope — an opaque ciphertext blob
produced inside the hub before anything reaches a store (see
Core 02 — Enclave). Archival relocates that envelope as-is:
no re-encryption, no plaintext on the wire, and the cold store — like any
store — never sees your data. You declare which records are eligible
with a per-collection archive policy (typically a business-date test like "year ≤ 2022"),
and an optional legalHold predicate that retains a record even when it is eligible
(litigation or audit hold). Archived records disappear from normal reads until you
restore() them.
Prerequisites
- A cold target store —
withArchive({ store })takes a second store the archived envelopes move to; anyNoydbStoreworks (see Stores).
Opt in
withArchive({ store }) designates the cold store; each collection that should
participate declares an archive policy when it is opened:
import { createNoydb, withArchive } from '@noy-db/hub'
import { toFile } from '@noy-db/to-file'
const db = await createNoydb({
store: toFile({ dir: './data' }), // primary store (hot)
user: 'owner',
secret: 'correct horse battery staple',
archiveStrategy: withArchive({
store: toFile({ dir: './archive' }), // cold store — any NoydbStore works
}),
})
const vault = await db.openVault('books')
interface Invoice {
id: string
year: number
status: 'draft' | 'issued' | 'paid'
hold?: boolean
}
const invoices = vault.collection<Invoice>('invoices', {
archive: {
archiveWhen: (r) => r.year <= 2022, // eligibility: closed fiscal years
legalHold: (r) => r.hold === true, // optional: retain despite eligibility
},
})
Any NoydbStore can be the cold target — a slower disk path, an object-storage adapter
from the extended to-* family, anything that satisfies the store contract.
Usage
Sweep eligible records to cold storage
vault.archive() walks every collection that declared an archive policy, evaluates
archiveWhen (and legalHold) on each decrypted record, and relocates the eligible
envelopes:
await invoices.put('inv-2020-001', { id: 'inv-2020-001', year: 2020, status: 'paid' })
await invoices.put('inv-2021-004', { id: 'inv-2021-004', year: 2021, status: 'paid' })
await invoices.put('inv-2021-007', { id: 'inv-2021-007', year: 2021, status: 'paid', hold: true })
await invoices.put('inv-2025-002', { id: 'inv-2025-002', year: 2025, status: 'issued' })
const result = await vault.archive()
{
"archived": 2,
"held": 1,
"scanned": 4,
"byCollection": { "invoices": { "archived": 2, "held": 1 } }
}
Archived records are gone from the primary store and invisible to normal reads:
await invoices.get('inv-2020-001')
// → null (relocated to the cold store)
await invoices.get('inv-2025-002')
// → { id: 'inv-2025-002', year: 2025, status: 'issued' }
Preview a sweep without moving anything with dryRun, or cap a large sweep with
maxArchives:
await vault.archive({ dryRun: true })
// → counts what *would* happen; no envelope moves
await vault.archive({ maxArchives: 500 })
// → stops after 500 relocations; run again to continue
List the archived set
The cold store's contents are themselves the archived-set index — there is no separate metadata to maintain:
await vault.listArchived()
// → [
// { collection: 'invoices', id: 'inv-2020-001' },
// { collection: 'invoices', id: 'inv-2021-004' },
// ]
await vault.listArchived('invoices') // scope to one collection
Restore a record
vault.restore(collection, id) relocates one envelope back to the primary store and
removes the cold copy:
await vault.restore('invoices', 'inv-2020-001')
// → true
await invoices.get('inv-2020-001')
// → { id: 'inv-2020-001', year: 2020, status: 'paid' }
await vault.restore('invoices', 'no-such-id')
// → false (not in the archive)
API
withArchive(opts)
function withArchive(opts: WithArchiveOptions): ArchiveStrategy
Enables record cold-storage archival. Pass the result as archiveStrategy to
createNoydb.
| Option | Type | Purpose |
|---|---|---|
store | NoydbStore | The cold store that holds archived record envelopes. Required. |
ArchivePolicy<T>
Declared in the collection options: vault.collection<T>(name, { archive: { … } }).
Evaluated when vault.archive() runs.
| Field | Type | Purpose |
|---|---|---|
archiveWhen | (record: T) => boolean | Selects records eligible for archival — typically a business-date / period test. Required. |
legalHold | (record: T) => boolean | Optional. While true, blocks archival of an otherwise-eligible record. Fail-closed: if it throws, the record is retained. |
vault.archive(options?)
vault.archive(options?: ArchiveRunOptions): Promise<ArchiveResult>
Sweeps eligible records into the cold store.
ArchiveRunOptions | Type | Purpose |
|---|---|---|
maxArchives | number | Stop the sweep after this many relocations. Omit for unbounded. |
dryRun | boolean | Count what would happen without relocating anything. |
Returns an ArchiveResult:
| Field | Type | Meaning |
|---|---|---|
archived | number | Records relocated to the cold store (or that would be, under dryRun). |
held | number | Records eligible by archiveWhen but retained by a legalHold. |
scanned | number | Records evaluated across all policy collections. |
byCollection | Record<string, { archived: number; held: number }> | Per-collection breakdown. |
vault.restore(collection, id)
vault.restore(collection: string, id: string): Promise<boolean>
Relocates one archived envelope back to the primary store and deletes the cold copy.
Returns false if the record is not in the archive.
vault.listArchived(collection?)
vault.listArchived(collection?: string): Promise<Array<{ collection: string; id: string }>>
Lists archived record ids for one collection, or for every collection with an archive
policy when called without arguments.
Exported types
WithArchiveOptions, ArchiveStrategy, ArchivePolicy, ArchiveResult, and
ArchiveRunOptions are all importable from the @noy-db/hub root.
Behavior when NOT opted in
- Collections may still declare an
archivepolicy; it is inert until a strategy exists. vault.archive(),vault.restore(), andvault.listArchived()throw anErrorwhose message points at the fix:vault.archive/restore/listArchived require `archiveStrategy: withArchive({ store })` in createNoydb.
Limits & gotchas
- Archival bypasses guards. Relocation goes through low-level store
operations, so a record locked as immutable (e.g. an issued invoice under a WORM guard)
can still be archived. This is by design — archival is housekeeping, not a user edit —
but it means a guard is not a defense against
vault.archive(). - Materialized views do not recompute. Archiving never fires view dispatch, so finalized aggregates over a closed period keep their values even after the underlying records move to cold storage.
- Archived records vanish from every normal read.
get()returnsnull, and queries do not see them. Restore first if a flow needs the record back. - A sweep decrypts every record in each policy collection to evaluate
archiveWhenon plaintext (inside the hub — the stores still only ever see ciphertext). On large collections, bound the work withmaxArchivesand sweep incrementally. - Fail-closed predicates. A throwing
archiveWhenskips the record (it is not archived); a throwinglegalHoldretains the record (counted asheld). A record that cannot be decrypted is skipped and not counted inscanned. - The cold store is the only index.
listArchived()enumerates the cold store directly; if you pointwithArchiveat a different store later, previously archived records are simply not listed (they remain wherever they were written). - Policies are declared per session, not persisted.
archiveWhen/legalHoldare functions registered whenvault.collection(name, { archive })runs. A sweep — and a no-argumentlistArchived()— only covers collections opened with their policy in the current session, so open every participating collection before callingvault.archive().
See also
- Services overview · Orientation map
- Core 02 — Enclave — what an encrypted envelope is
- Core 05 — Stores — the store contract the cold target must satisfy
- guards — write-boundary locks that archival deliberately bypasses
- periods — fiscal-period seals that pair naturally with year-based
archiveWhenpolicies - Proven by showcase
127-with-archive.