routing
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — routeStore, wrapStore, and the middleware factories are direct named imports, always available. |
What it does
Multi-store routing lets a single noy-db vault (the end-to-end-encrypted document store — see Vaults & collections) spread its data across more than one storage backend: most collections on one database, blob attachments on cheap object storage, an audit-log collection on its own bucket, aging records migrated to a cold archive. routeStore() builds that multiplexer; the result satisfies the same store contract as a single backend (see Stores), so createNoydb({ store }) never has to know the difference.
Alongside it, wrapStore() composes resilience and observability middleware around any store, routed or not: retry with backoff, a circuit breaker, health-check auto-suspend, a read-through cache, structured logging, and metrics. Both routeStore() and wrapStore() are ordinary named exports of @noy-db/hub's main entry point — there's no with*() opt-in factory to call, and no separate subpath to import them from selectively. They're part of the core the same way createNoydb is.
When you need it
- Hot/cold tiering — recent records stay on a fast/local store, older ones migrate to cheap archive storage
- Multi-backend layouts — blob attachments to S3/R2, an audit-log collection to its own bucket, one vault region's data to an EU-only store
- Resilience around a flaky backend — retry transient failures, trip a circuit breaker on persistent ones, auto-suspend on failed health checks
- Observability — structured logging or a metrics callback around every store call
- Maintenance windows — suspend a backend, optionally buffer writes, resume and replay them later
Setup
import { createNoydb, routeStore, wrapStore, withRetry, withCircuitBreaker, withMetrics } from '@noy-db/hub'
import { toAwsDynamo } from '@noy-db/to-aws-dynamo'
import { toCloudflareR2 } from '@noy-db/to-cloudflare-r2'
const routed = routeStore({
default: toAwsDynamo({ table: 'myapp' }),
blobs: toCloudflareR2({
accountId: 'acct-1',
bucket: 'myapp-blobs',
credentials: async () => ({ kind: 'aws', accessKeyId: '…', secretAccessKey: '…' }),
}),
routes: {
auditLog: toCloudflareR2({
accountId: 'acct-1',
bucket: 'myapp-audit',
credentials: async () => ({ kind: 'aws', accessKeyId: '…', secretAccessKey: '…' }),
}),
},
})
const resilient = wrapStore(
routed,
withRetry({ maxRetries: 3 }),
withCircuitBreaker({ failureThreshold: 5 }),
withMetrics({ onOperation: (op) => metrics.record(op) }),
)
const db = await createNoydb({ store: resilient, user: 'alice', secret: 'correct horse battery staple' })
wrapStore() takes the store followed by any number of middleware — the first middleware listed is outermost (sees the request first, the response last).
Usage
Hot/cold tiering with age-based routing
import { createNoydb, routeStore } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const hot = toMemory()
const cold = toMemory()
const store = routeStore({
default: hot,
age: { cold, coldAfterDays: 90 },
})
const db = await createNoydb({ store, user: 'alice', secret: 'correct horse battery staple' })
const vault = await db.openVault('acme')
await vault.collection('invoices').put('inv-1', { id: 'inv-1', amount: 100 })
const migrated = await store.compact('acme')
// → 0 (inv-1 was just written — nothing is older than 90 days yet)
compact() scans age-eligible collections and moves any record whose _ts is older than coldAfterDays from the hot store to cold; reads transparently fall through to cold when the hot store returns nothing, so callers never need to know which tier a record lives in.
Since 0.3.0-pre.8, compact() also accepts an explicit
cutoff — store.compact(vault, { before: isoTimestamp }) migrates every record with
_ts < before regardless of coldAfterDays — and coldAfterDays itself became optional:
age: { cold } alone declares a cold tier used only by explicitly-cut-off migrations. That
per-call form is what the periods service's vault.archivePeriod(name)
drives: period-driven cold archival of a closed accounting period, with the cutoff derived from
the period's end date instead of a rolling age.
Resilience and metrics middleware
import { wrapStore, withRetry, withCircuitBreaker, withMetrics } from '@noy-db/hub'
import { toAwsDynamo } from '@noy-db/to-aws-dynamo'
const resilient = wrapStore(
toAwsDynamo({ table: 'myapp' }),
withRetry({ maxRetries: 3 }),
withCircuitBreaker({ failureThreshold: 5, onOpen: () => console.warn('backend unavailable') }),
withMetrics({ onOperation: (op) => console.log(op) }),
)
await resilient.get('acme', 'invoices', 'inv-1')
{
"method": "get",
"vault": "acme",
"collection": "invoices",
"id": "inv-1",
"durationMs": 4,
"success": true
}
Maintenance window: suspend, buffer, resume
store.suspend('default', { queue: true })
// writes to the default route now buffer in memory instead of reaching the backend
await vault.collection('invoices').put('inv-2', { id: 'inv-2', amount: 50 })
const replayed = await store.resume('default')
// → 1 (the buffered put replays against the restored store)
store.routeStatus()
{ "overrides": {}, "suspended": [], "queued": {} }
API reference
routeStore(options)
function routeStore(options: RouteStoreOptions): RoutedNoydbStore
Builds the store multiplexer. Returns a RoutedNoydbStore — a full store plus runtime control methods — that can be passed directly to createNoydb({ store }).
RouteStoreOptions
| Field | Type | Purpose |
|---|---|---|
default | NoydbStore | Required. Store for every operation no other rule matches. |
blobs | NoydbStore | { small?, large, threshold? } | Routes blob chunk data to a dedicated store. Pass a size-tiered object to send blobs under threshold bytes (default 400 * 1024) to small/default and larger ones to large. |
routeBlobMeta | boolean | Also route blob index/slot/version metadata to the blobs store. Default false. |
routes | Record<string, NoydbStore> | Route specific collection names (exact match) to dedicated stores. |
vaultRoutes | Record<string, NoydbStore> | Route by vault name prefix (e.g. 'EU-'). Checked before routes. |
age | { cold, coldAfterDays?, collections? } | Hot/cold tiering: records older than coldAfterDays migrate to cold on compact(); reads fall through to cold when the hot store returns null. coldAfterDays is optional since 0.3.0-pre.8 — omit it for period-driven archival only, where compact(vault, { before }) supplies the cutoff per call. |
blobRoutes | Record<string, NoydbStore> | Accepted and its stores are included in loadAll/saveAll/listVaults, but not yet wired to routing decisions — storeFor() never consults it, so blob chunks are never routed by MIME type or storeHint. |
blobLifecycle | { orphanRetentionDays?, archiveAfterDays?, archiveStore?, expireAfterDays? } | Accepted and archiveStore (if set) is included in loadAll/saveAll/listVaults, but not yet evaluated anywhere — compact() only performs age-based hot/cold migration and never reads this option. No orphan cleanup, archival, or expiry runs. |
overflow | NoydbStore | Accepted and included in loadAll/saveAll/listVaults, but the quota check that would activate it is not yet wired up — it is hardcoded to never trigger, so writes always go to default regardless of usage. |
quotaThreshold | number | Overflow trigger, 0–1. Default 0.8. Has no effect while overflow is unwired (see above). |
There is no glob-pattern list evaluated in order — routes and vaultRoutes are plain object maps, matched by exact collection name and vault-name prefix respectively. Resolution order for a given vault + collection: an active runtime override/suspend wins first, then vaultRoutes (prefix), then routes (exact name), then blob routing, then quota overflow, then default.
RoutedNoydbStore — extra methods beyond the store contract
| Method | Returns | Purpose |
|---|---|---|
compact(vault, opts?) | Promise<number> | Migrates age-eligible records from hot to cold; returns the count migrated. opts.before (ISO timestamp, since 0.3.0-pre.8) migrates everything with _ts < before instead of the rolling coldAfterDays window. |
override(route, store, opts?) | void | Promise<void> | Swap a named route's backing store at runtime. opts.hydrate (true, or a collection-name array) only switches the return type to a Promise — it does not copy any data itself; you must copy records from the original store into the new store before or during the call (see below). |
clearOverride(route) | void | Revert a route to its original store. |
suspend(route, opts?) | void | Turn a route into a no-op sink (get → null, put/delete → no-op, list → []). opts.queue: true buffers writes for later replay; opts.maxQueueSize bounds the buffer (default 10_000, oldest dropped first). |
resume(route) | Promise<number> | Reactivate a suspended route; replays any queued writes and returns how many succeeded. |
routeStatus() | RouteStatus | Snapshot: { overrides, suspended, queued }. |
resolveBackend(vaultId) | NoydbStore | Resolve which store a vault id maps to via vaultRoutes, falling back to default. Used for diagnostics. |
route above is an OverrideTarget: the literal 'default', 'blobs', 'cold', or any custom name — a collection from routes, a prefix from vaultRoutes.
Capabilities of a routed store
Since 0.3.0-pre.8, a routeStore() surfaces its primary (default) store's capabilities
(previously it exposed none), layering coldArchival: true on top when — and only when — the
router itself declares an age cold route. Two consequences:
- Features gated on a store capability now see through the router: e.g. the gap-free
sequence().next()requirescasAtomic, and is permitted on a routeStore-backed vault whenever the primary store reports it — previously it refused on any routed store. coldArchivalis whatvault.archivePeriod()checks before archiving. A router without its own cold route never advertises it, even when nested over a cold-capable primary.
wrapStore(store, ...middlewares)
type StoreMiddleware = (next: NoydbStore) => NoydbStore
function wrapStore(store: NoydbStore, ...middlewares: StoreMiddleware[]): NoydbStore
Composes any number of middleware around a store, left-to-right — the first argument after store is outermost. wrapStore takes middleware as variadic arguments, not an array.
Middleware
| Factory | Options | Purpose |
|---|---|---|
withRetry(opts?) | maxRetries (default 3), backoffMs (default 500), jitter (default 0.3), retryOn?: string[] | Retries a failed call with exponential backoff and jitter. retryOn limits retries to matching error.codes; omitted, every error retries. |
withLogging(opts?) | level (default 'info'), logger?, logData (default false) | Logs every call's method, arguments, and duration. Envelope payloads are redacted unless logData: true. |
withMetrics(opts) | onOperation: (op: StoreOperation) => void | Calls onOperation after every call with { method, vault, collection?, id?, durationMs, success, error? }. |
withCircuitBreaker(opts?) | failureThreshold (default 5), resetTimeoutMs (default 30_000), onOpen?, onClose? | Opens after failureThreshold consecutive failures, short-circuiting to safe fallbacks (null/[]/{}) until a half-open probe succeeds. |
withCache(opts?) | maxEntries (default 500), ttlMs (default 60_000) | Read-through LRU cache for get(). put/delete invalidate the entry immediately; list/loadAll/saveAll bypass the cache entirely. Caches encrypted envelopes, not plaintext. |
withHealthCheck(opts?) | checkIntervalMs (default 30_000), suspendAfterFailures (default 3), resumeAfterSuccess (default 1), onSuspend?, onResume?, check? | Pings the store on an interval (store.ping(), or a custom check()); auto-suspends the store after repeated failures and auto-resumes on recovery. |
Availability
There is no opt-in step: routeStore, wrapStore, and the middleware factories are core exports of @noy-db/hub, available to every consumer whether or not they're used. Plain single-store usage (createNoydb({ store: someStore })) simply never calls them.
Pairs well with
- bundle — a pod store (
wrapPodStore/createPodStore, from@noy-db/hub/pod) can be one leg of arouteStore()setup, e.g. archiving to a.noydbfile - lazy — bounded-memory
prefetch: falsecollections combine naturally withage-based hot/cold routing - indexing — required if a routed store's lazy-mode collections need
lazyQuery()
Edge cases & limits
overflow,quotaThreshold,blobRoutes, andblobLifecycleare accepted byRouteStoreOptionsand their stores participate inloadAll/saveAll/listVaults, but none of the four affects a routing decision in the current version: quota-triggered overflow can never trigger, andblobRoutes/blobLifecycleare never read bystoreFor()orcompact(). Configuring them is a no-op beyond making those stores reachable for bulk export/import.resolveBackend()only consultsvaultRoutes; it does not account for per-collectionroutesoverrides — it exists for the vault-prefix geographic-routing case specifically.- The circuit breaker's fallback values are type-shaped but empty (
get→null,list→[],loadAll→{}) — callers see "no data" rather than an error while the circuit is open. PairwithCircuitBreakerwithwithRetry(listed before it) so transient errors are absorbed before they count toward the failure threshold. withCacheis store-level: it caches whateverget()returns per vault/collection/id.list,loadAll, andsaveAllalways bypass it, so cached data can't go stale from a bulk write you didn't alsoget()through the same wrapped store.- Size-tiered
blobsrouting ({ small, large, threshold }) can only pick a store by size at write time —put()sees the envelope's byte length, but a plainget()/list()route for blob chunks defaults tolargewhen it can't determine size up front. suspend(route, { queue: true })drops the oldest queued write oncemaxQueueSizeis reached; size the queue for your expected outage window if you rely on it.override(route, store, { hydrate: true })does not copy any data —hydrateonly makesoverride()return aPromiseinstead of resolving synchronously. You are responsible for copying every record from the route's current store into the new store yourself, e.g.await copyAllRecords(oldStore, newStore)before callingoverride(), or inline inside your ownasyncwrapper around it. Skip this and the route switches to an empty store.
See also
- bundle — pod stores as a routing target
- lazy — bounded-memory collections that pair well with hot/cold routing
- blobs — the usual reason to route: chunked binary data on a separate backend
- indexing — required for
lazyQuery()on a routed store's lazy collections - Stores — the store contract every route and middleware layer satisfies
- periods —
vault.archivePeriod(), the period-driven consumer of the cold route - Proven by showcase
05-storage-routing.