Content temporarily unavailable
Frozen grammar v4: app-facing error model for a reactive local-first data library#
A Collection holds rows kept in sync with a backend by a sync adapter. A live query reads one or more Collections ("sources", one per query alias) and is itself a Collection. Apps read a live query through framework hooks. Some Collections are persisted: they restore rows from local disk storage and also sync with a remote backend.
Surface#
- status: 'idle' | 'loading' | 'ready' | 'error' | 'crashed' | 'cleaned-up'
- One boolean per status: isIdle, isLoading, isReady, isLoadingError (status 'error'), isCrashed, isCleanedUp. isError is deprecated.
- isSyncError: status is 'ready' and the tree holds any failureReason or error.
- health: a tree, one node per query alias plus a root node for the query. Each node:
{ alias, kind: 'source'|'live-query'|'include', status, hasErrors, error | null, sources }.
A source node carries at most one of:
- failureReason: the latest problem while the adapter keeps trying (like TanStack Query's failureReason during retries);
- error: the adapter stopped (fail), or the gate failed. Each value is { name, message, code?, at, cause }. A node also shows reloading while a reload's new run is in flight. The query's own node carries a request error.
- collection.health: the Collection's current after-ready error, or null.
- collection.reload(): start a new sync run while keeping the current rows (see R7).
- collection.cleanup(): throws while the Collection has subscribers. The app can list a Collection's subscribers (live queries, Effects, raw subscriptions) and ask cleanup to clean up those dependents first.
Rules#
R1 Gate. Each sync run: loading -> ready | error | crashed. An adapter that keeps retrying before its first ready reports nothing (status stays loading). 'error' (gave up before the first ready) is final for the run. R2 Crash. 'crashed' = the Collection can no longer serve reads or accept writes: local storage broken (closed IndexedDB, a failed local write or restore) or an internal invariant failure. Final for the run; ends every live query and Effect over it. The cause may or may not persist into a new run. R3 Query gate. Until a live query is ready, it takes its sources' worst gate (crashed > error > loading > ready). After it is ready, only 'crashed' propagates. R4 After ready, a failure never changes status or removes rows. It appears on the node that owns it:
- source node, sync problem: failureReason (adapter still trying; clears itself) or error (adapter stopped; rows frozen until reload);
- a source whose new sync run fails before its first ready while the query is ready (an on-demand source loading late, or a source being reloaded): node error; the query stays ready;
- query's own node, request error: a request for more data (next page, newly joined rows) failed; clears when that data loads or is no longer needed. R5 Adapter verbs: markReady(); reportError(e) = something went wrong, still running (sets failureReason); fail(e) = stopped (before ready -> status 'error'; after -> node error); reportHealthy() clears failureReason. A node error clears only when a reload's new run commits. R6 Tree identity is stable while content is unchanged; every field except cause is JSON. R7 Reload. reload() starts a new sync run like a sync-side truncate. Nothing visible changes until the new run's first commit or failure: rows, status ('ready' or 'error'), and the current error stay, and the node shows reloading. The first commit replaces all rows atomically and clears the node's problem (an 'error' status becomes 'ready'). If the new run fails first, the old rows and status stay and the node shows the new error. reload() while a reload is in flight returns that reload's promise. reload() on a 'crashed' Collection throws: a crash is not recovered in place; recovery is a cascade cleanup, which discards all state, and a fresh start (of the same object or a new one). reload() during 'loading' starts a new run; commits from the old run are ignored. A failed request is re-issued by the new run. R9 Live-query reload. reload() on a live query in 'error' reloads each of its sources in 'error' or with a node error. Its status stays 'error' while any source still has a failure, including a source whose reload is in flight (it keeps its 'error' gate). Once no source has a failure, the query moves to 'loading', or straight to 'ready'. Adapters retry inside their first load (for example query-db's retry or the queryFn) before calling fail(). R8 Persisted Collections. A completed local restore of previously synced rows opens the gate (status 'ready') even if the remote has not connected; remote problems after that are reported (R4/R5), including before the remote's first sync. A remote that fails before the restore finishes waits for the restore: synced rows -> 'ready' with node error; none -> 'error'. A failed restore is a crash. An empty restore of a Collection that never synced does not open the gate.
Invariants#
I1 isLoadingError is never true after the query has shown data in its run. I2 After a query is ready, its status is 'ready' or 'crashed' until it is recreated. I3 Status 'ready' implies rows are shown; no after-ready failure or reload blanks rows. I4 From what the app can observe, it can pick one remedy: wait, retry the action, reload, report broken storage. I5 No silent recovery or silent degradation: 'error' becomes 'ready' only through a reload's commit; 'crashed' never becomes 'ready' without a cleanup in between; a failing remote is never shown as healthy; a reload never shows 'loading' in place of 'error'. I6 'crashed' always means broken local storage or an invariant failure. I7 Rows shown never come from a superseded run. I8 'ready' never means "no rows" when the truth is "unknown".