stable

Store-locator descriptors

A credentialless, JSON-serializable StoreDescriptor plus a StoreLocator registry that reconstructs a live to-* store from data.

A second construction path. Alongside every to<Kind>() factory, each to-* store now exports a StoreDescriptor builder, a StoreFactory, and a register<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:

SlotCarriesRides in
addressWhere the backend isthe descriptor (serialized)
optionsSerializable tuningthe descriptor (serialized)
bindingDevice-local objects — a live client, a custom fetch, a mount path, a drive handleresolve()'s opts, device-side only
credentialsA 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