Stores
Always-on. The pluggable storage layer. Source of truth:
packages/hub/src/kernel/types.ts(NoydbStoreinterface)
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, neverexisting && existing._v !== expectedVersion. SoexpectedVersion: 0is an exact "write only if absent", and a nonzero expectation against a missing id throwsConflictError(carryingversion: 0) instead of upserting — the shorter guard silently upserted, which older stores written against hub0.10.0-pre.1and earlier may still do. Don't implement absence by throwing either: the first create must succeed. saveAllreplaces, 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_keyringreplaces the live keyring without passing through any keyring code path. Treat asaveAllpayload 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 throughput,getandlist. Ids are case-distinct:put('Abc')andput('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/ports0.10.0-pre.2;to-fileandto-mysqleach 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
}
@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:
| Method | Purpose | Fallback 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 path | Full loadAll + slice (one-time console warning) |
presencePublish(channel, payload) / presenceSubscribe(channel, cb) | Real-time presence pub/sub | Storage-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:
| Package | Factory | casAtomic | Notes |
|---|---|---|---|
@noy-db/to-file | toFile({ dir }) | ✗ | One JSON file per record at dir/vault/collection/id.json; version check is read-then-write |
@noy-db/to-browser-idb | toBrowserIdb() | ✓ | IndexedDB; check + write in a single readwrite transaction |
@noy-db/to-meter | toMeter(inner) | inherited | Wraps 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:
| Store | casAtomic | Notes |
|---|---|---|
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