stable

routing

Multi-store routing (routeStore()) and composable store middleware (wrapStore() + retry/circuit-breaker/health/cache/logging/metrics) — a core feature.
Subpath@noy-db/hub (root export)
TypeCore 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

FieldTypePurpose
defaultNoydbStoreRequired. Store for every operation no other rule matches.
blobsNoydbStore | { 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.
routeBlobMetabooleanAlso route blob index/slot/version metadata to the blobs store. Default false.
routesRecord<string, NoydbStore>Route specific collection names (exact match) to dedicated stores.
vaultRoutesRecord<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.
blobRoutesRecord<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.
overflowNoydbStoreAccepted 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.
quotaThresholdnumberOverflow 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

MethodReturnsPurpose
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)voidRevert a route to its original store.
suspend(route, opts?)voidTurn 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()RouteStatusSnapshot: { overrides, suspended, queued }.
resolveBackend(vaultId)NoydbStoreResolve 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() requires casAtomic, and is permitted on a routeStore-backed vault whenever the primary store reports it — previously it refused on any routed store.
  • coldArchival is what vault.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

FactoryOptionsPurpose
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) => voidCalls 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 a routeStore() setup, e.g. archiving to a .noydb file
  • lazy — bounded-memory prefetch: false collections combine naturally with age-based hot/cold routing
  • indexing — required if a routed store's lazy-mode collections need lazyQuery()

Edge cases & limits

  • overflow, quotaThreshold, blobRoutes, and blobLifecycle are accepted by RouteStoreOptions and their stores participate in loadAll/saveAll/listVaults, but none of the four affects a routing decision in the current version: quota-triggered overflow can never trigger, and blobRoutes/blobLifecycle are never read by storeFor() or compact(). Configuring them is a no-op beyond making those stores reachable for bulk export/import.
  • resolveBackend() only consults vaultRoutes; it does not account for per-collection routes overrides — 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. Pair withCircuitBreaker with withRetry (listed before it) so transient errors are absorbed before they count toward the failure threshold.
  • withCache is store-level: it caches whatever get() returns per vault/collection/id. list, loadAll, and saveAll always bypass it, so cached data can't go stale from a bulk write you didn't also get() through the same wrapped store.
  • Size-tiered blobs routing ({ small, large, threshold }) can only pick a store by size at write time — put() sees the envelope's byte length, but a plain get()/list() route for blob chunks defaults to large when it can't determine size up front.
  • suspend(route, { queue: true }) drops the oldest queued write once maxQueueSize is reached; size the queue for your expected outage window if you rely on it.
  • override(route, store, { hydrate: true }) does not copy any data — hydrate only makes override() return a Promise instead 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 calling override(), or inline inside your own async wrapper 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.