Store-locator descriptors
A second construction path. Alongside every
to<Kind>()factory, eachto-*store now exports aStoreDescriptorbuilder, aStoreFactory, and aregister<Kind>Store()— plain data in, a live store out.
The problem this solves
Calling toPostgres({ client, table: 'noydb_envelopes' }) directly is fine
when the same code both knows the connection and builds the store. It stops
being fine the moment "which store, and where" needs to be recorded —
written into a pod, a manifest, a sync payload, or a Studio config screen —
and reconstructed later, possibly on a different device, possibly by code
that has never heard of to-postgres.
A StoreDescriptor is that record: plain JSON stating which store and
where, with nothing in it that a secrets audit would flag.
interface StoreDescriptor {
readonly kind: string // registered factory key, e.g. 'postgres'
readonly class: StoreClass // 'local' | 'browser' | 'lan' | 'cloud'
readonly address: unknown // kind-specific serializable location
readonly options?: unknown // kind-specific serializable tuning
}
Credentialless by construction
No field on a StoreDescriptor may be a function, a StoreCredentialSource,
or a StoreCredentials value — enforced by the type, not by convention. A
descriptor is safe to persist alongside the rest of a pod's manifest without
the review a secret would demand, because there is structurally nowhere on
it for a secret to hide.
Everything that isn't serializable identity travels separately, supplied to
resolve() at the moment a descriptor becomes a live store:
| Slot | Carries | Rides in |
|---|---|---|
address | Where the backend is | the descriptor (serialized) |
options | Serializable tuning | the descriptor (serialized) |
binding | Device-local objects — a live client, a custom fetch, a mount path, a drive handle | resolve()'s opts, device-side only |
credentials | A StoreCredentialSource (the hub's credential-broker seam) | resolve()'s opts, device-side only |
class: the closed union
StoreClass is 'local' | 'browser' | 'lan' | 'cloud' — a broad topology
bucket a caller or a UI can group and filter descriptors by without knowing
every concrete kind string. It says nothing about auth or transport, only
where the backend physically lives relative to the device resolving the
descriptor.
The four slots, in practice
address is where a store's factory disagrees the most from store to
store, and deliberately so — each borrows its own ecosystem's vocabulary so
a descriptor reads as familiar rather than invented: { database, schema, table } for Postgres, { url, table } for Turso, { bucket, region, prefix } for S3. Some address fields are consumed by the factory (table
maps to a store's tableName); others are identity-only — present so a
persisted descriptor is legible to a human or to Studio, but not read by the
factory because the live connection already carries that information (a
Postgres database/schema, an SMB host/share). Each store's page
states which is which.
binding carries what is device-local, named for what it actually is.
Where a store injects a live client, the field is client, uniformly,
across every store that takes one — whatever the store's own direct-call
option calls it (db, smb, sftp, fs), the factory maps binding.client
onto it. Where the device-local thing isn't a client — a WebDAV transport
override, an NFS mount point — the binding field is named for that instead
(fetch, mountPath), because calling a filesystem path client would be a
lie in service of uniformity.
Some stores carry no client library at all — no pg, no mysql2, no
@libsql/client — by design, so the adapter stays dependency-free and the
caller owns pooling, retries, and TLS. For those, binding.client is
required, not an escape hatch: the descriptor is portable identity rather
than a construction recipe, and resolve() throws a clear, consistently
worded error if it's missing.
credentials is a StoreCredentialSource — an async function the store
invokes to get a StoreCredentials value (an AWS key pair, a bearer token,
…) — supplied to resolve(), never to the descriptor. It is how the same
descriptor resolves against a caller's ambient credential broker on one
device and a different one on another.
Precedence
Inside a factory, later always wins:
options → address → binding
A device-local binding supersedes anything derived from address or
options — an injected client always wins over region/endpoint, a
binding.dir override always wins over address.dir. This is what makes a
descriptor authored on one machine usable on another: the parts that can't
travel are exactly the parts a fresh binding supplies.
A descriptor may only set declared keys
address and options are typed unknown on StoreDescriptor, so nothing
structurally stops a caller putting an extra key in either bag. From
0.6.0-pre.1 every extended to-* factory destructures the named fields it
declares instead of spreading that bag into the store options, so an
undeclared key cannot reach the store at all.
This was a real hole, not a tidiness fix, and it only bit where the winning
key was applied conditionally. to-nfs is the sharp case: mountDetector is a
binding-owned slot applied only when binding.mountDetector was absent, so a
matching key smuggled through options survived and won — silently disabling
the nolock / noac / fstype safety diagnostics that are the entire reason
that store exists. to-postgres had the same shape via tableName (applied
only when address.table was set).
The practical consequence: a key that used to quietly take effect now quietly
does nothing. If you were relying on an undeclared key, move it to the slot
that declares it — a device-local object belongs in binding, not in the
serialized bag.
The six exports
Every to-* store that has adopted the seam exports the same shape:
export interface <Kind>Address // serializable "where"
export interface <Kind>DescriptorOptions // serializable tuning; omitted where none exists
export interface <Kind>Binding // device-local objects; omitted where none exists
export function <kind>StoreDescriptor(address, options?): StoreDescriptor
export const <kind>StoreFactory: StoreFactory
export function register<Kind>Store(locator: StoreLocator): void
This is purely additive — to<Kind>() is unchanged and remains the direct
construction path. Nothing about existing callers moves.
Worked example
import { createStoreLocator } from '@noy-db/hub/to'
import { postgresStoreDescriptor, registerPostgresStore } from '@noy-db/to-postgres'
import { Client } from 'pg'
// 1. Build a descriptor — plain JSON, safe to persist.
const descriptor = postgresStoreDescriptor(
{ database: 'app', schema: 'public', table: 'noydb_envelopes' },
{ autoMigrate: true },
)
// 2. Register the kinds a given process knows how to resolve.
const locator = createStoreLocator()
registerPostgresStore(locator)
// 3. Later — possibly after a round-trip through a pod or a config
// screen — resolve back to a live store. The connection is device-local
// and supplied here, never carried on the descriptor.
const client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
const store = await locator.resolve(descriptor, { binding: { client } })
// 4. Use it like any other NoydbStore.
await store.ping()
JSON.parse(JSON.stringify(descriptor)) round-trips descriptor exactly —
that equality is the credentialless guarantee made concrete: nothing was
lost, because nothing sensitive was ever on it.
When to reach for this instead of to<Kind>()
Call to<Kind>() directly when the code that knows the connection is the
same code building the store — the common case, and still the simplest one.
Reach for the descriptor path when "which store, and where" needs to be
data: written into a pod's manifest so a vault records its own storage
topology, offered as an option in a configuration UI without hardcoding a
switch over every to-* package, or handed across a process boundary
(main → renderer, server → CLI) where only one side holds the live
connection. The descriptor is the part that travels; binding and
credentials are supplied wherever the descriptor lands.
See also
to-*family index — the full storage-destination catalog- choosing a store — picking a backend per platform