stable

Choosing a store

How to pick a to-* store per platform, why the hub ships no pickStore() helper, and the one real gap in the platform matrix.

Every to-* factory returns the same NoydbStore contract, so choosing between them is a plain conditional at construction time. There is no pickStore() helper, and that absence is a decision rather than an oversight.

Why there is no helper

A pickStore() in the hub would have to know about the to-* packages in order to choose between them. That means either importing them — which the one-way seam law forbids, since the hub may never import a satellite — or taking them as arguments, at which point it is the conditional you already wrote with a function call wrapped around it.

It would also be the hub's only API aware of satellite packages, and it would sit on the always-on floor that the bundle-size gate exists to keep small.

Environment detection is genuinely the app's job. It depends on bundler targets, SSR, React Native, Electron main-versus-renderer, and test environments — things the hub cannot see and should not guess.

The pattern

import { createNoydb } from '@noy-db/hub'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
import { toFile } from '@noy-db/to-file'

const store =
  typeof indexedDB !== 'undefined'
    ? toBrowserIdb()
    : toFile({ dir: './data' })

const db = await createNoydb({ store, user, secret })

Prefer feature detection over platform sniffing. typeof indexedDB !== 'undefined' survives bundling, SSR and test environments; process.platform does not.

Multi-store topologies

Two composition seams, for when one store is not enough:

  • routeStore() multiplexes one handle across many backends by rule — per collection, per vault prefix, blobs to object storage, age-based cold tiering, quota overflow. It returns a real store plus a runtime control surface (override, suspend, compact), so routes can be swapped or suspended without rebuilding the instance.
  • withSync({ sync: [...] }) takes a primary remote, or an array of { store, role } targets — so "local platform store plus cloud replica" is expressible per platform.

They nest freely. You can meter a routed store, or route to metered stores:

const pg = toMeter(toPostgres({ /* … */ }))
const s3 = toMeter(toAwsS3({ /* … */ }))
const store = routeStore({ default: pg, blobs: s3 })

A store's locality changes what an unlock error MEANS

This is a store-choice consequence, and it is easy to get wrong because the error is identical either way.

When an unlock fails — a KeyringTamperedError, say — many apps translate it into advice. That translation is only valid for a local store, and it must branch on two things, in this order.

1. The reason code, not the message. error.details.reason is a discriminated label, and one value is explicitly not an attack: 'format-superseded' means the file declares an older keyring format than this build writes — a format transition. Say so, and name error.details.format.from → .to. The absence reasons — 'canary-missing', 'roster-key-missing', 'roster-tag-missing' — are genuinely unclassifiable: a store that stripped a field and a file written before the field existed are byte-identical, so they must be presented as neither an upgrade nor an attack.

2. Where the store is. Advice that is correct for a local store (to-browser-idb, to-file) can be harmful for a remote one:

storethe same error can meanso "remove the vault from this device"
local (to-browser-idb, to-file)the device is already compromisedis reasonable — there is no evidence to preserve
remote (by-peer, to-rest, a LAN daemon)the store altered your data⛔ destroys the only copy of the evidence
a thin guest holding nothing locallysame as remote⛔ is also meaningless — there is nothing to remove

⚠️ The remote case is not hypothetical and it does not need a server. peerStore() from @noy-db/by-peer routes every store operation to another machine, so a guest opening a vault served by someone else's device is on a remote, separately administered store — with no cloud involved.

⭐ In both branches, say what to preserve before removing anything. "Export or copy the raw store contents first, then remove" is safe under either premise and no less actionable. The safe advice does not depend on knowing which case you are in — which is the point, because for the absence reasons you cannot know.

The platform matrix, honestly

PlatformStoreStatus
Browser@noy-db/to-browser-idbships
Node / Electron main / CLI@noy-db/to-fileships
Tests, REPL, hot cache@noy-db/to-memoryships
SQL / cloud / remote filesystemsthe noy-db-to familyships
macOS (Node)@noy-db/to-icloudships — with a caveat below
iOS / iPadOSto-cloudkitnot shipped

The Apple gap is real, and worth knowing before you design around it. to-icloud is Node-on-macOS only: it reaches the iCloud Drive folder through the filesystem. iOS and iPadOS have no equivalent folder access, so they need CloudKit — which is deferred (noy-db-to#16). Until it ships, an iOS target has no first-party Apple-native store. Use a cloud store from the noy-db-to family, or to-browser-idb inside a web view.

One further caveat on to-icloud: modern macOS evicts to APFS dataless files rather than .icloud stubs, which makes its eviction detection macOS-version-sensitive (noy-db-to#15).

See also