Skip to content

Offline limits and cache lifecycle

Everything here is a limit of the current release, not of the model. Each is stated so an application can design around it rather than discover it.

New here? Start with Offline and synchronization.

Per signed-in account, per server, per database:

  • the committed replica — one complete authorized value at one opaque revision;
  • the outbox — queued invocations, their order, and the references this device minted for entities it created;
  • receipts — the authoritative answer to each invocation, so a lost acknowledgement never commits twice;
  • optimistic layers — the declared projection for each invocation still in flight.

Query answers are never stored. Every answer is derived from the replica, which is why a query written tomorrow still runs over yesterday’s offline data.

The four are physically separate. A replica that must be discarded — an incompatible schema, a read-view change — is discarded on its own; queued work, receipts and reference mappings survive it, because an invocation’s identity does not depend on the shape of the data it will produce.

A stored replica becomes visible in exactly two ways.

An exact prior binding. The same bearer that this replica was last confirmed under is presented again. The replica renders immediately, marked stale, before anything reaches the network — this is the offline start.

A current confirmation. The bearer changed. cacheKey may then nominate the stored replica, and nothing more: it is an account selector, it never leaves the device, and it is not authority. The client shows nothing until the current authenticated response confirms the same replication identity and the same read compatibility. A mismatch, an unknown key, or no network at all shows no prior data.

What changedWhat happens
Documentation, defaults, an operation body, a deploymentNothing. The replica is reused with no reset and no download.
Field schema, entity or trait composition, or read policyThe replica’s representation is quarantined and replaced. Queued work, receipts and reference mappings survive.
A different principal was authenticated under the same cacheKeyEvery tab holding the previous principal is fenced through storage before the replacement renders. It is fenced, not deleted: the previous principal’s own bearer can still restore it in a later session.
The application signed out, or signed in under a different cacheKeyNothing. There is no authentication to carry the replacement, so the previous account’s data stays until it is cleared.
Storage pressureStored content nothing reaches any more is reclaimed and the write is retried. If that is not enough the write fails with a typed quota error rather than discarding the replica.
await client.clearLocalData()Everything for the confirmed account, across every tab. The client is terminal afterwards; construct a new one.

Sign-out is therefore explicit: call clearLocalData() before dropping the client, while the account is still confirmed — with none confirmed it fails and deletes nothing rather than guessing which data was yours.

One catalog per client. A client installs one catalog for its one authorized database.

Optimistic updates are declared, never inferred. An operation with no declared projection shows nothing locally until the server answers. The projection is a separate, explicit changeset because the operation body is deployed code the browser neither has nor may run.

Entity handles come from the database that answers them. db.query.from(Issue) publishes live handles with .local and .mutate. A select(…) projects the focus away and publishes plain rows, and a query built through the portable Ramose.Query.from and reused across databases carries no focus either.

Local ids are never public. Every id an application sees is an EntityId the server issued or a ClientRef this device minted. The numeric id inside the local store is meaningless off the device and is reassigned by a delete and recreate, so it is never handed out.

A nested filter cannot name an entity. where({ author }) takes an EntityId, but the where inside a select(…) collection compiles into the query value before any binding exists, so one there is refused.

Suspense waits for loading, never for connectivity. useQuery reports pending as a state to render. useSuspenseQuery waits under a boundary instead, but only while a first local answer could still arrive: a restored replica renders stale without suspending, and an offline scope with nothing cached reads pending rather than holding a fallback with no end.

Without a lock manager, every tab synchronizes for itself. Where the browser offers no lock manager, correctness is unchanged — concurrent writers converge on the same durable records and one invocation still commits once — but the network work is duplicated per tab.

Reading resumes when the tab is activated again, not the instant the network returns. A database left offline activates again the next time the tab is focused, shown, restored from the back/forward cache, or told the device is online. A hidden tab waits to be looked at; what was already committed stays readable throughout, and no new client is needed.