stable

Stores

The pluggable ciphertext backend: the NoydbStore contract, the three stores that ship with noy-db, and the extended cloud/SQL/remote-FS family.

Always-on. The pluggable storage layer. Source of truth: packages/hub/src/kernel/types.ts (NoydbStore interface)

What it is

noy-db encrypts every record inside the hub — the core library — before anything is written out. A store is the pluggable backend that persists the result: memory, a directory of JSON files, IndexedDB, S3, Postgres, and so on. The hub hands the store an encrypted envelope (the ciphertext wrapper described in Enclave) and gets ciphertext back. The store never sees plaintext and never runs a query — the query DSL runs inside the hub, after decryption, so the backend stays a dumb, untrusted ciphertext shelf.

Three essential backends ship with noy-db itself, on top of the in-memory store built into the hub; the extended cloud / SQL / remote-FS stores are published from noy-db-to. You can write your own in ~150 lines.

The contract

Six required methods; everything else is optional. vault is the compartment name, collection the record group inside it, id the record key.

interface NoydbStore {
  /** Optional human-readable name for diagnostics ('memory', 'file', 'dynamo', …). */
  name?: string

  /** Optional declared capabilities — see StoreCapabilities below. */
  capabilities?: StoreCapabilities

  /** Get a single record. Returns null if not found. */
  get(vault: string, collection: string, id: string): Promise<EncryptedEnvelope | null>

  /** Put a record. Throws ConflictError if expectedVersion doesn't match the current _v,
   *  where an absent id counts as version 0 (since hub 0.10.0-pre.2). */
  put(vault: string, collection: string, id: string,
      envelope: EncryptedEnvelope, expectedVersion?: number): Promise<void>

  /** Delete a record. */
  delete(vault: string, collection: string, id: string): Promise<void>

  /** List all record IDs in a collection. */
  list(vault: string, collection: string): Promise<string[]>

  /** Load all records for a vault (initial hydration). */
  loadAll(vault: string): Promise<VaultSnapshot>

  /** Save all records for a vault (restore). REPLACES since hub 0.10.0-pre.2 — see below. */
  saveAll(vault: string, data: VaultSnapshot): Promise<void>
}

VaultSnapshot is Record<string, Record<string, EncryptedEnvelope>> — collection → id → envelope.

Two rules a store must get exactly right, both tightened in hub 0.10.0-pre.2 and asserted by the @noy-db/ports/to conformance kit:

  • An absent record is at version 0. Compare (existing?._v ?? 0) !== expectedVersion, never existing && existing._v !== expectedVersion. So expectedVersion: 0 is an exact "write only if absent", and a nonzero expectation against a missing id throws ConflictError (carrying version: 0) instead of upserting — the shorter guard silently upserted, which older stores written against hub 0.10.0-pre.1 and earlier may still do. Don't implement absence by throwing either: the first create must succeed.
  • saveAll replaces, it does not merge. Every non-_ collection is dropped and the snapshot written over the top, so a collection absent from the snapshot is gone and an empty record set empties its collection. Reserved _-prefixed collections are never dropped, but are overwritten when the snapshot carries them — so a snapshot carrying _keyring replaces the live keyring without passing through any keyring code path. Treat a saveAll payload as exactly as trusted as the vault itself.
  • Ids are opaque, within limits. An id may contain / and a NUL byte (hub emits both — a partitioned sequence key joins its parts with \x00), and must round-trip through put, get and list. Ids are case-distinct: put('Abc') and put('abc') are two records, or the store refuses the second write. Hub refuses a non-single-segment vault or collection name, and an id with an empty, . or .. component, before any store sees it. The kit asks all of these since @noy-db/ports 0.10.0-pre.2; to-file and to-mysql each failed one when it was added.

Every one of these types (plus the store-facing error classes such as ConflictError) is importable from @noy-db/hub/to, the ciphertext-facing subpath that standalone store packages bind. They are also re-exported from the root @noy-db/hub.

To match a conflict a store may have thrown, use the isConflictError predicate (instanceofor name === 'ConflictError') rather than a bare instanceof (noy-db#935). It is what every hub-internal catch site that handles a store-thrown conflict — the sync engine's push channels, the CAS retry loops — uses, and it exists for a failure the class check cannot survive: when npm fails to dedupe hub within the peer range, a store bound to a second copy of @noy-db/hub/to throws a ConflictError with a foreign class identity. instanceof silently returns false, so a retry loop rethrows instead of retrying and the sync engine misfiles the conflict with no resolution run.

import { isConflictError } from '@noy-db/hub/to'

try {
  await store.put(vault, collection, id, envelope, expectedVersion)
} catch (err) {
  if (isConflictError(err)) {
    // re-read, re-apply, retry — matches across duplicate hub copies
  }
  throw err
}
Import it from @noy-db/hub/to if you are writing a store. Until 0.7.0-pre.6 the predicate was exported only from the root barrel while /to exported the ConflictErrorclass — the wrong way round for the one seam that needs it, since a standalone store package binds /to and nothing else. Store authors were pointed at a function they could not import, and the obvious fallback, instanceof ConflictError off /to, is precisely the defect the predicate prevents. 0.7.0-pre.6 adds it to /to (noy-db#1224); the root export is unchanged, so in-app code that already imports it from @noy-db/hub keeps working.

Declared capabilities

interface StoreCapabilities {
  casAtomic: boolean        // is the expectedVersion check + write atomic at the storage layer?
  auth: StoreAuth           // how the backend authenticates: kind ('none' | 'filesystem' |
                            //   'api-key' | 'iam' | 'oauth' | 'kerberos' | 'browser-origin'),
                            //   required, and flow
  serverWriteTime?: boolean // store exposes an authoritative clock via getStoreTime()
  region?: string           // advisory geographic region ('eu', 'us') for data-residency checks
  txAtomic?: boolean        // store implements tx() with storage-layer atomicity
  maxBlobBytes?: number     // max raw bytes per blob chunk; undefined = no limit
}

Consumers that need a capability read it here — for example vault.sequence().next() requires casAtomic: true.

Optional extension methods

Stores may implement any of these; the hub feature-detects them (e.g. 'listPage' in store) and falls back gracefully when absent:

MethodPurposeFallback when absent
ping()Connectivity check for the sync engine—
getStoreTime()Authoritative store clock as a { earliest, latest } interval; present iff capabilities.serverWriteTime—
listSince(vault, collection, since)IDs modified after since, for partial sync (pull({ modifiedSince }))Full loadAll + client-side timestamp filter
listPage(vault, collection, cursor?, limit?)Cursor-based pages of envelopes; the streaming scan() fast pathFull loadAll + slice (one-time console warning)
presencePublish(channel, payload) / presenceSubscribe(channel, cb)Real-time presence pub/subStorage-based polling
listVaults()Enumerate every vault the store holds, for listAccessibleVaults()StoreCapabilityError with guidance
presignUrl(vault, collection, id, expiresInSeconds?)Time-limited direct-download URL (object stores); the URL serves ciphertext—
estimateUsage(){ usedBytes, quotaBytes } or null, for quota-aware routing—
tx(ops)Native multi-record atomic write backing db.transaction(fn)Per-record optimistic concurrency with best-effort unwind

Authoring a store

A complete, working store — a flat Map keyed by vault/collection/id. createStore is a type-safe identity helper for store factories:

import { createNoydb, createStore, ConflictError } from '@noy-db/hub'
import type { NoydbStore, EncryptedEnvelope, VaultSnapshot } from '@noy-db/hub'

export const mapStore = createStore((opts: { name?: string }): NoydbStore => {
  const data = new Map<string, EncryptedEnvelope>()
  const key = (v: string, c: string, id: string) => `${v}/${c}/${id}`

  return {
    name: opts.name ?? 'map',
    capabilities: {
      // Synchronous Map ops: the version check and the write cannot interleave.
      casAtomic: true,
      auth: { kind: 'none', required: false, flow: 'static' },
    },
    async get(v, c, id) {
      return data.get(key(v, c, id)) ?? null
    },
    async put(v, c, id, envelope, expectedVersion) {
      const current = data.get(key(v, c, id))?._v ?? 0   // absent = version 0
      if (expectedVersion !== undefined && current !== expectedVersion) {
        throw new ConflictError(current)
      }
      data.set(key(v, c, id), envelope)
    },
    async delete(v, c, id) {
      data.delete(key(v, c, id))
    },
    async list(v, c) {
      const prefix = `${v}/${c}/`
      return [...data.keys()].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length))
    },
    async loadAll(v) {
      const snapshot: VaultSnapshot = {}
      for (const [k, envelope] of data) {
        const [vault, collection, id] = k.split('/')
        if (vault !== v) continue
        ;(snapshot[collection] ??= {})[id] = envelope
      }
      return snapshot
    },
    async saveAll(v, snapshot) {
      // Replace, not merge: drop every non-`_` collection of this vault first.
      for (const k of [...data.keys()]) {
        const [vault, collection] = k.split('/')
        if (vault === v && !collection.startsWith('_')) data.delete(k)
      }
      for (const [collection, records] of Object.entries(snapshot)) {
        for (const [id, envelope] of Object.entries(records)) {
          data.set(key(v, collection, id), envelope)
        }
      }
    },
  }
})

const db = await createNoydb({ user: 'alice', secret: 's3cret', store: mapStore({}) })
const vault = await db.openVault('acme')
await vault.collection('contacts').put('c-1', { name: 'Ada' })

What the store actually received in put is an envelope — metadata plus opaque ciphertext, never the record:

{
  "_noydb": 1,
  "_v": 1,
  "_ts": "2026-07-08T09:30:00.000Z",
  "_iv": "…base64…",
  "_data": "…base64 ciphertext…"
}

One optional metadata field a custom store must round-trip untouched: _del: true marks a delete marker — the envelope a synced collection's delete() writes instead of a physical removal (empty body, version-ordered, so the delete converges across peers; since 0.3.0-pre.7). The hub treats it as "record absent" on read; the store just persists it like any other envelope. See sync → deletes converge as delete markers.

Every official store is held to the contract by a shared conformance suite: runStoreConformanceTests() (from @noy-db/ports/to) exercises the six methods and the ConflictError semantics in each to-* package's CI — against mocks, no real cloud service. When writing your own store, mirror the behaviors documented in the contract above; the version-conflict semantics of put are the part most implementations get wrong.

Capabilities by essential store

Three essential packages. to-file and to-browser-idb publish from noy-db's core repo; to-meter — a wrapper, not a backend — publishes from noy-db/in since 0.10.0-pre.1. All three share the @noy-db/* scope and the same install line:

PackageFactorycasAtomicNotes
@noy-db/to-filetoFile({ dir })✗One JSON file per record at dir/vault/collection/id.json; version check is read-then-write
@noy-db/to-browser-idbtoBrowserIdb()✓IndexedDB; check + write in a single readwrite transaction
@noy-db/to-metertoMeter(inner)inheritedWraps any store; times and counts every call, zero semantic changes. Also ships the store diagnostics runStoreProbe(store) / probeTopology(…), absorbed from the retired @noy-db/to-probe

No store at all is also valid: createNoydb() with no store falls back to memoryStore(), which is built into @noy-db/hub itself — not the @noy-db/to-memory package. The two are capability-equivalent and easy to conflate, but hub ships with zero dependencies, so the default can never be the package. Reach for @noy-db/to-memory when you want the fuller store as an explicit, swappable choice; the built-in default is there so createNoydb() works with no wiring at all.

Extended stores (in noy-db-to) include:

StorecasAtomicNotes
to-memory✓Map-of-maps; the fuller in-memory store. Relocated from noy-db to noy-db-to on 2026-09-17 — same @noy-db/to-memory name, same contract, so an existing install line keeps working; only the publishing repo and version line changed
to-browser-local✗localStorage; atomic within one tab, no cross-tab serialization
to-aws-dynamo✓ConditionExpression on the envelope version
to-aws-s3✓S3 conditional writes (IfMatch / IfNoneMatch)
to-postgres✓Version check + upsert; native tx() via BEGIN … COMMIT
to-cloudflare-r2✓S3-compatible conditional writes
to-cloudflare-d1✓UPDATE … WHERE v = ? inside a D1 batch

Full list: the to-* family.

Store routing & middleware

Multi-backend routing, retry / circuit-breaker / metrics middleware, lazy-mode caching, and bundle-store wrapping live in the routing service.

See also

  • Enclave — what is inside the envelope the store persists
  • The to-* family — every official storage backend
  • Routing — composing multiple stores