Skip to content

Browser client

ramose/client owns authorized browser replicas, database handles, local query execution, synchronization, the durable outbox, optimistic layers, and receipts. Author the named schema through ramose/db, then pass that shared value to the client.

New here? Start with Build your first Ramose web app.

Infer live entity fields, identity, and mutation methods directly from the schema:

import type { EntityHandleFor } from "ramose/client"
type TaskHandle = EntityHandleFor<typeof Task>

Use this type for component props instead of maintaining a second handwritten model.

import { createClient } from "ramose/client"
const client = createClient({
url: "https://data.example.com",
database: "app",
schema: AppSchema,
auth: () => ({ token: session.accessToken, cacheKey: session.user.id }),
})

database names the public route /db/:database/* — immutable configuration, not a database identity, and never a mutation receiver.

auth returns { token, cacheKey } atomically and may refresh. cacheKey selects an account across token renewal; it is hashed with the origin and database route, never sent or stored raw, and grants no authority. Only a prior bearer binding or the current authenticated response makes stored data observable.

Construction is inert: nothing opens until the first query is observed. client.close() releases network scopes without discarding queued work. clearLocalData() is the separate destructive operation, after which that client is terminal.

const rootDb = client.open()

open() takes no arguments and returns one interned database handle. Callers cannot choose an internal database or catalog id.

const tasks = rootDb.observe(
rootDb.query.from(Task).orderBy(Task.createdAt, "desc"),
)
const unsubscribe = tasks.subscribe(() => render(tasks.getSnapshot()))

observe returns a subscription — subscribe(onChange) plus getSnapshot() — React’s external-store contract. Identity changes only when the value does.

type QuerySnapshot<A> = {
status: "pending" | "ready" | "error"
data: A | undefined
stale: boolean
error: Error | undefined
}

stale means the local value is not confirmed by the current session: a restored replica, or a reconnect. Disposing the last observer stops maintaining that query; the replica stays readable offline.

An entity-focused query returns live handles. A projection does not — select(...) projects the focus away, and a projection is not an entity:

task.id // the opaque EntityId, or a ClientRef
task.data.title // plain, cloneable, no methods
task.local.pending // this client's own outstanding work
task.mutate.setDone({ done: true })

A handle is live and interned: one object per entity per row shape, whose .data is replaced as the view changes. Holding it across renders is safe, and an entity created offline keeps the same object when its EntityId arrives.

.data is plain cloneable data, no methods and no metadata. .local.pending stays true while any invocation holds optimistic state, including after its receipt commits and before replication observes it. .mutate carries what the entity’s type and traits declare.

Every id here is an EntityId, or a ClientRef for something created offline — a row’s id, a nested reference cell, .ids(), select({ id: Task.id }), and a declared entity-reference input. A local numeric id is never public: it is meaningless off this device, and a delete and recreate reassigns it.

They are also what the next query filters by — where({ assignee: me.id }). See the query builder.

const receipt = projectDb.mutate.createTask({ title: "Ship docs" })

Targetless operations live on db.mutate, targeted ones on entity.mutate. Both return the same durable receipt.

Both namespaces are derived from the installed catalog: a misspelled name and a wrong-shaped input are compile errors, and entity.mutate carries the entity’s own operations and its traits’. Presence never depends on what this principal may do: a refusal is a rejected receipt, not a missing method.

A declared optimistic projection updates local queries before the acknowledgement.

await receipt.queued // this invocation is durable
await receipt.committed // the server executed it, exactly once
type ReceiptState =
| { status: "pending" }
| { status: "queued" }
| { status: "committed" }
| { status: "rejected"; error: MutationRejectedError }
| { status: "failed"; error: Error }

A receipt is a subscription too, so it can be observed as well as awaited. The two durable transitions are separate. queued means the invocation survives a restart and will be submitted; it claims nothing about the server. committed means the authoritative receipt, output and mappings are durable.

rejected is the server’s answer to an invocation that was durable. failed is a pre-queue failure that promises nothing was written, so a queued invocation can never become one.

Observe a receipt when the outcome matters; use .local.pending for lightweight feedback.

client.sync and db.sync are subscriptions shaped like a query’s, reporting idle, connecting, live, stale, offline, authentication-required, update-required and closed. A ready query can be useful offline; a live connection does not imply every receipt committed.

The activated database has a persistent IndexedDB replica. Tabs share durable data and elect one network leader per principal and database; closing a tab does not discard queued work.