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.
Type component inputs
Section titled “Type component inputs”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.
createClient
Section titled “createClient”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.
Open the authorized database
Section titled “Open the authorized database”const rootDb = client.open()open() takes no arguments and returns one interned database handle. Callers cannot choose an internal database or catalog id.
Observe a query
Section titled “Observe a query”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.
Entity handles
Section titled “Entity handles”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 ClientReftask.data.title // plain, cloneable, no methodstask.local.pending // this client's own outstanding worktask.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.
Entity ids are opaque
Section titled “Entity ids are opaque”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.
Targetless operations
Section titled “Targetless operations”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.
Receipt state
Section titled “Receipt state”await receipt.queued // this invocation is durableawait 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.
Synchronization state
Section titled “Synchronization state”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.
Persistence and multi-tab behavior
Section titled “Persistence and multi-tab behavior”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.