Skip to content

Content temporarily unavailable

Draft law: persisted demand coverage

Status: implemented in PR #2263, which also contains the oracle, the matching ARCHITECTURE.md and glossary edits, and the review record (2026-10-21-persisted-demand-coverage-review.md). The law revised an accepted contract; it was not a defect report under the old one. It belongs to issue #2102, item 1. Later sections record maintainer decisions made during review, with their dates.

Question

A user turned on SQLite persistence for an app. initialRender: { strategy: 'network-first' } worked for eager Collections but not for progressive Electric ones. They expected progressive Collections to render from SQLite without waiting for the network.

Electric reports progressive to core as on-demand (packages/electric-db-collection/src/electric.ts:1216). The persisted wrapper rejects network-first for on-demand (packages/db-sqlite-persistence-core/src/persisted.ts:6833). Every on-demand source has this limit, not only Electric.

The product need is offline use. Offline, an on-demand source can serve a query only from an exact query that was persisted before, unless everything was synced. Serving everything is one reason to use progressive mode.

Evidence gathered

Two witness files record current behavior on main at 31dad2dd3. They state no contract.

packages/db-sqlite-persistence-core/tests/on-demand-offline-readiness-witness.test.ts uses a generic on-demand source:

UpstreamLocal subsetQuery rowsQuery ready
never settlescompletea1, a2no
never settlespartiala1no
never settlesemptynoneno
settles at once, no writecompletea1, a2yes
settles at once, no writeemptynoneyes

packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts runs two Electric progressive sessions over one real node:sqlite database. Session 2 is offline:

Session 1Session 2 resumes streamQuery rowsQuery ready
completed full syncyes (10_0, shape-1)1, 2yes
loaded the query subset onlyno1, 2no
nothingnononeno

What the evidence establishes, for these histories:

  1. Restored rows reach the query before readiness in every on-demand case. Only the readiness signal waits.
  2. After a completed progressive full sync, Electric resumes and settles loads at once. The query is ready on SQLite rows with no network.
  3. If the full sync never completed, the persisted subset rows are present, but the query waits on the network with no deadline.
  4. The wrapper cannot tell a complete subset from a partial or never-loaded one. All three read back as rows, or as no rows.

The witnesses do not cover React or Solid Suspense timing, native SQLite hosts, cache-claim rotation, or a must-refetch during the offline session.

The law

Persisted demand coverage. An opted-in persisted wrapper over an on-demand source may settle a demand's load from SQLite. It may do this when all of these hold:

  1. The persisted subset restore for the demand has published its rows in the current sync run. The rows may be empty.
  2. The exact demand has persisted demand coverage in the current persisted cache generation.
  3. The configured wait has passed since the load started, or the demand's upstream load has failed.

All three must hold, in any order of arrival. An upstream failure that arrives before the persisted subset restore publishes settles the demand once the restore publishes. Until then, the load waits for the upstream load as it does today. A demand without coverage never settles from SQLite.

After the load settles from SQLite, its upstream load continues as work that the wrapper owns. Rows from that load publish as ordinary source writes. A later upstream failure does not undo the settlement. Releasing the demand, or cleanup, aborts the upstream load. Coverage survives release: a later load of the same demand starts its own wait and may settle from SQLite again. Settling from SQLite does not change Collection readiness.

An upstream load that settles successfully records coverage for its exact demand. Coverage is written durably with the load's rows or after them, and records how many rows the demand reads from SQLite at that moment. Three more rules came from review:

  • A load that a reset made obsolete records no coverage (law 6 in ARCHITECTURE.md).
  • A load with refetch: true never settles from SQLite. It starts a new acquisition that waits for its own applied rows.
  • Coverage counts only while the persisted subset restore returns at least as many rows as coverage recorded. A row deleted afterwards, by the source or by Query DB retention, ends coverage until the demand loads online again (maintainer decision, 2026-10-26). An unordered demand counts only rows: one that loses a row and gains another keeps its count and stays covered, even when retention removed the lost row (maintainer decision, 2026-10-26, chosen over recording every row key, whose storage grows with the demand).
  • Coverage is bookkeeping beside a load. A coverage read that fails proves nothing: the load records no coverage, or waits for its upstream load, and otherwise keeps its own outcome. A load released during its coverage check owns no wait.

The first draft also let a certified complete resume baseline cover every demand. No path implemented that, and a source that resumes from a complete baseline already settles its own loads at once, so the clause was dropped (maintainer decision, 2026-10-26).

Terms

These require glossary entries; the draft entry is in docs/contributing/glossary.md.

  • persisted subset restore: the wrapper's read of one demand's request data from the current persisted cache generation, and publication of those rows to the Collection, in the current sync run. The result may be empty.
  • persisted demand coverage: a record, in one persisted cache generation, that a load of one exact demand reached applied settlement with its rows durably committed.

Why coverage matches by exact demand

Coverage matches by the canonical demand key (getLoadSubsetDemandKey). The law never infers that one demand covers a different one. #1797 made the same choice for request deduplication. It removed subset algebra after it treated "requests as established coverage." Law 7 in ARCHITECTURE.md already says settlement proves no broader extent than the exact request.

Without coverage, an empty or partial local result is not evidence. The witnesses show the wrapper cannot tell "no matching rows" from "never loaded." A fallback on such a demand would render a false empty or partial result.

Coverage records completeness at the time of the covered load. It says nothing about freshness, just as an eager restore can be stale.

What follows without new rules

The live-query architecture routes every readiness consumer through demand settlement. Settling a covered load therefore reaches all of them:

  • initial-query readiness and preload();
  • React and Solid Suspense, which follow initial-query readiness;
  • queryOnce;
  • skipInitial Effects;
  • atomic ordered-window publication, so a covered next page publishes offline;
  • demand added after the first render, such as includes children and new filters.

ARCHITECTURE.md already states that a source resuming from complete local rows settles its loads at once. This law applies the same idea to one demand instead of the whole Collection. The live query adds one query-level signal, described next.

Queries over several sources

The sources combine. A query may render from local data when every source permits it:

  • an eager source, after its persisted restore completes in the current sync run;
  • an on-demand source, after every demand the query reaches on it has settled, from SQLite or from the network.

A source failure does not change these conditions. It never settles a demand, and it does not keep a settled demand from permitting a render: a covered demand that settles from SQLite after a source reports an error still permits the fallback render, as an eager source's restore does after a source failure.

This is initial-query readiness with one substitution: an eager source's persisted restore stands in for its Collection readiness. A query over only on-demand sources needs no substitution, because its readiness already follows its loads. The eager network-first law in persisted-readiness-oracle.test.ts is unchanged for a query over only eager sources.

Today the observer's network-first gate runs only when every source registers a PersistedReadinessSource (collectPersistedReadinessSources in packages/db/src/live-query-observer.ts). That interface reports one state per Collection, but demand settlement differs by query: two queries over the same on-demand source can be in different states. An opted-in on-demand source therefore registers only that it opted in, with no settlement state. The gate reads settlement from the live query itself: the query's own initial readiness, with each eager source's persisted restore standing in for that source's Collection readiness. An opt-in alone never permits fallback; that would release a join before the on-demand side's covered rows are visible.

The live query does not expose that signal today. preloadForInitialRender combines the network preload() with per-Collection persisted snapshots; it cannot ask whether the query's own demands have settled independently of eager Collection readiness. The implementation must add that query-level signal: initial readiness with each eager source's persisted restore in place of its Collection readiness.

Update, 2026-10-27: the source-health change (#2168) removed the observer's network-first gate and PersistedReadinessSource. A network-first eager Collection now becomes ready from its restored rows after the deadline or a remote failure, so the substitution above happens in Collection status itself. Query readiness is the signal, and this law adds nothing to core: an on-demand source permits initial readiness, and the query is ready when its demands on it have settled. How a source error reaches the query's status is the source-health law (packages/db/tests/source-health-oracle.test.ts); the two failure paragraphs above are superseded by it.

The wait

The wait reuses the network-first option and its default. #1955 set that default to 3 seconds (DEFAULT_NETWORK_TIMEOUT_MS), and 0 is valid. The wait is a preference for fresher data while online, not a correctness condition: removing it breaks no property above. At 0, a covered demand behaves like a demand on a completed progressive resume today.

Each load measures its wait from its own start. Only that load's own upstream failure ends the wait early.

Invalidation

Coverage belongs to one persisted cache generation. It ends with that generation and with any reset that replaces the durable rows: truncate, must-refetch, schema reset, or cache-claim rotation. A record that memory still holds after a rotation in the same sync run does not count. A crash between writing the rows and writing the coverage must not leave coverage without rows. Coverage also ends when the restore returns fewer rows than it recorded.

Update, 2026-10-28 (high review of the head after #2168): three consequences of existing terms are now enforced.

  • A snapshot transaction replaces every synced row, as a truncate at its start does (glossary, "snapshot transaction"), so it ends coverage too.
  • A load that core aborted decides nothing, even if its source resolves it (glossary, "abort"), so it records no coverage.
  • Scoped recovery ends coverage and does not certify the new generation for offline reuse (docs/collections/electric-collection.md). Its persisted subset restore publishes no rows, so coverage cannot count during the rest of that sync run: no load settles from SQLite and none records.

Limits

  • Coverage lifetime after release. After a demand is released, its source stops sending changes for it. Coverage stays valid until its generation ends or a later load of the same demand settles. An expiry option, like Query DB's persistedGcTime, is tracked as a separate issue and is not implemented.
  • Keys that depend on other rows. Cursor pages and includes children build demand keys from other rows' values. If restored rows differ from the previous session, the key differs too. That demand is then uncovered and waits for the network.
  • Uncovered demand while offline. It waits indefinitely, also after a source reports an error: the network-first wait neither renders nor rejects. The in-progress error-reporting work owns how that condition is reported.
  • A failure after a settlement from SQLite. The query stays ready and the failure does not reach it, so the application cannot tell that a refresh failed. The in-progress error-reporting work owns whether such a failure is reported elsewhere; the guides must state the behavior.
  • Optimistic writes while offline. An optimistic mutation that touches a covered demand's rows is outside this law. Optimistic overlay rules apply as usual; the oracle checks one write and its rollback on a demand settled from SQLite.
  • Ordered windows. A window settles from SQLite only while its restore holds the rows it held when it settled (maintainer decision, 2026-10-26). Coverage of a limited load records its row keys, and the restore must return the same keys in any order. The tie-boundary load's key comes from the restored boundary row, so most changed windows also ask for a key that no load covered; the recorded keys catch a row replaced or gained without a boundary change.
  • Failed coverage reads. A coverage read that fails, including one under an expired cache claim, proves nothing. The wrapper records no coverage, and a load that would settle from SQLite waits for the network. Neither path fails a load whose rows applied. An expired claim also starts claim recovery. A failed coverage write is a durable failure, which fails the runtime as any failed source transaction does.
  • Source settlement. The coverage read waits behind every source transaction already committed, so it counts their rows durably, also for a source that answers synchronously. It relies on the source committing a load's rows before it settles that load (law 7 in ARCHITECTURE.md).
  • Multi-tab coordinators. By maintainer decision (2026-10-26), the law covers only the default SingleProcessCoordinator. Under a Browser or Electron multi-tab coordinator, demand routes through the coordinator, so a covered demand neither settles from SQLite nor records coverage; it waits for the network as before.
  • Electric's own load. Electric's upstream load may stay pending after the wrapper settles from SQLite. Its release and abort ownership need a receiving test.

Oracle design

Owner. packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts owns this law. It sits beside the eager network-first owner, persisted-readiness-oracle.test.ts, under the same coverage-map section. The live-query consequences listed above stay with their existing owners. A receiving witness is needed for the window-publication case.

Independent model. For each demand, the model records the local restore (pending, done, or error) and the coverage (covered, uncovered, or baseline). It also records the upstream state (pending, settled, or failed) and whether the wait has passed. From these it computes whether the load may settle and which rows are visible at settlement. It shares no code with the wrapper.

History grammar. Session 1 produces coverage through real loads, not seeded metadata. Seeded metadata would make the model's coverage and the production coverage the same assumption. Session 2 steps then arrive in any legal order:

  • the local restore settles or fails;
  • the upstream load settles or fails;
  • the wait passes;
  • a reset or rotation happens;
  • the demand is released;
  • a second demand starts.

Production driver. The real persisted wrapper over SQLiteCorePersistenceAdapter and node:sqlite, with a generic on-demand source. Receiving witnesses cover Electric progressive (full-sync resume and subset-only) and Query DB.

Observations. After each step: load settlement, query rows, isReady(), whether preload() has settled, whether the network-first wait has released, and, for an ordered query, the published window. A joined history pairs an eager source with an on-demand source.

The glossary's persisted readiness and network-first initial render entries follow this combining rule (edited in this worktree).

Wrong designs the oracle must reject. Each needs a demonstrated failure:

  1. Removing the on-demand guard and using startup readiness. This settles before the subset restore, with no rows.
  2. Treating any completed local restore as evidence. This fails "offline, empty, uncovered" and "offline, partial, uncovered."
  3. Treating nonempty rows as coverage. This fails "covered and genuinely empty."
  4. Writing coverage before the rows commit durably. This fails a crash between the two writes.
  5. Keeping coverage across reset or rotation.
  6. Matching a different demand key, such as limit 10 coverage for limit 20.
  7. Aborting the upstream load when the wrapper settles from SQLite. Fresh rows then never arrive.
  8. Leaving a covered next page unpublished after the wait.
  9. An on-demand opt-in that permits fallback on its own. The gate then releases a join before the on-demand side's covered rows are visible.
  10. Coverage keyed by filter only, or ignoring order while honoring limit.
  11. A wait measured from the sync run instead of the load, or coverage cleared on release.
  12. A late upstream failure that undoes a settlement from SQLite.

Documents that change with the law

  • Persisted-readiness oracle: prose, model, grammar, driver, and checks.
  • packages/db/src/query/live/ARCHITECTURE.md: source settlement and the initial-readiness limits (edited in this worktree).
  • docs/contributing/glossary.md: new persisted subset restore and persisted demand coverage entries; revised persisted readiness and network-first initial render entries (edited in this worktree).
  • docs/guides/sqlite-persistence.md ("Render after SQLite restore") and docs/guides/live-queries.md. They must say that a covered result, including an empty one, is complete as of the load that covered it, not fresh, and that the wait changes when a query is ready, not how fresh its rows are. Rows from the network can still arrive after a query is ready.
  • docs/contributing/oracle-coverage.md, "Persisted readiness and network-first initial rendering."
  • docs/contributing/oracle-reviews/issue-2056-cache-eviction-design.md, per issue #2102's completion criteria.