stable

archive

Cold-storage archival: designate a second store with `withArchive({ store })`, sweep old records out with `vault.archive()`, pull one back with `restore()`.
Subpathexported from @noy-db/hub root (no dedicated subpath)
FactorywithArchive()
Layerwith-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; any NoydbStore works (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.

OptionTypePurpose
storeNoydbStoreThe 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.

FieldTypePurpose
archiveWhen(record: T) => booleanSelects records eligible for archival — typically a business-date / period test. Required.
legalHold(record: T) => booleanOptional. 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.

ArchiveRunOptionsTypePurpose
maxArchivesnumberStop the sweep after this many relocations. Omit for unbounded.
dryRunbooleanCount what would happen without relocating anything.

Returns an ArchiveResult:

FieldTypeMeaning
archivednumberRecords relocated to the cold store (or that would be, under dryRun).
heldnumberRecords eligible by archiveWhen but retained by a legalHold.
scannednumberRecords evaluated across all policy collections.
byCollectionRecord<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 archive policy; it is inert until a strategy exists.
  • vault.archive(), vault.restore(), and vault.listArchived() throw an Error whose 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() returns null, 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 archiveWhen on plaintext (inside the hub — the stores still only ever see ciphertext). On large collections, bound the work with maxArchives and sweep incrementally.
  • Fail-closed predicates. A throwing archiveWhen skips the record (it is not archived); a throwing legalHold retains the record (counted as held). A record that cannot be decrypted is skipped and not counted in scanned.
  • The cold store is the only index. listArchived() enumerates the cold store directly; if you point withArchive at 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 / legalHold are functions registered when vault.collection(name, { archive }) runs. A sweep — and a no-argument listArchived() — only covers collections opened with their policy in the current session, so open every participating collection before calling vault.archive().

See also