stable

rest

HTTP adapter for noy-db — a store that speaks to an @noy-db/in-rest ciphertext relay, so the server holds encrypted blobs it cannot open.

@noy-db/to-rest · pnpm add @noy-db/to-rest Talks to an @noy-db/in-rest relay over HTTP.

Shaperecord
Atomic CAS✓ (delegated to the server's store)
Localityremote
Speednetwork-bound
Persistencedurable (whatever the server's store provides)

What it does

Sends encrypted envelopes to an HTTP endpoint running @noy-db/in-rest. Every store method POSTs { method, args } to {baseUrl}/rpc; the relay forwards the call to its own backing store and returns the result unchanged.

Encryption stays on this side. The client encrypts before the request and decrypts after the response, so the server handles ciphertext only — the same trust model as any other to-* store, reached over a network hop instead of a disk or an SDK.

import { createNoydb } from '@noy-db/hub'
import { toRest } from '@noy-db/to-rest'

const db = await createNoydb({
  store: toRest({
    baseUrl: 'https://relay.example.com/api/noydb',
    headers: { authorization: `Bearer ${API_KEY}` },
  }),
  user: 'alice',
  secret: 'a-strong-secret',
})

What it pairs with

The server half is @noy-db/in-rest's createRestHandler, which needs >= 0.6.0-pre.0. Earlier in-rest versions exposed a different HTTP surface — one that unlocked vaults server-side — which has been removed. There is no package dependency between the two; the coupling is the wire protocol.

Notes

  • ConflictError is re-hydrated from any 409 whose error body is named ConflictError, so compare-and-set semantics survive the wire hop. Match it with isConflictError, never instanceof — the error is constructed inside to-rest's copy of @noy-db/hub/to, which need not be your caller's copy.
    Upgrade to 0.7.0-pre.1 or later. Through 0.7.0-pre.0 the re-hydration also required typeof version === 'number', so a 409 that omitted the field fell through to a generic Error — a lost CAS race arriving as something no retry loop can recognize. 0.7.0-pre.1 keys on the name alone.
    server sends0.7.0-pre.00.7.0-pre.1
    409, name: 'ConflictError', no versionError — isConflictError falseConflictError, .version NaN
    409, name: 'ConflictError', version: 3ConflictError, .version 3ConflictError, .version 3
    409 named anything elseErrorError
  • .version is NaN on any conflict from a current relay. in-rest stopped sending the field in 0.7.0-pre.7 — it disclosed another writer's progress counter (noy-db#1218, closed) — so its 409 body is now { name, message } and nothing else. NaN is deliberate, chosen over a -1 sentinel so it cannot masquerade as a real stored counter. Test with Number.isNaN(err.version) before using it and treat a wire conflict as conflict, position unknown: re-read and re-apply rather than reasoning from the number. ConflictError raised by a local store still carries a real version; only the wire hop drops it.
  • 401 / 403 / 501 / 5xx map to auth, capability, not-implemented and server errors respectively — a client can branch on them without parsing prose.
  • Trailing optional arguments (expectedVersion, cursor, limit) are trimmed before serialization: JSON has no undefined, and a null must not reach the server looking like a real value.
  • Atomicity is whatever the server's backing store offers — the relay does not add or remove guarantees.
  • Factory: toRest(options: RestStoreOptions).

Store-locator descriptor

See store-locator descriptors for the general model. kind: 'rest', class: 'cloud'. to-rest's direct options violate the credentialless rule twice — fetch is a function, and headers is where a static Authorization lives — so they're partitioned rather than forwarded: address is { baseUrl }, options is { timeoutMs? }, and the device-local fetch plus non-auth headers (tenant routing, tracing) ride binding. Auth rides RestStoreOptions.credentials, a kind: 'token'StoreCredentialSource that becomes Authorization: Bearer <token>. Header assembly happens per request, so an expiring token refreshes without rebuilding the store — the source is invoked once per operation, and expiresAt is not consulted (unlike to-webdav/to-turso, which cache within a refresh-skew window).

import { restStoreDescriptor, registerRestStore } from '@noy-db/to-rest'

const descriptor = restStoreDescriptor({ baseUrl: 'https://relay.example.com/api/noydb' })
// later: locator.resolve(descriptor, { credentials: async () => ({ kind: 'token', token }) })

See also