diff --git a/.changeset/on-demand-network-first.md b/.changeset/on-demand-network-first.md
new file mode 100644
index 0000000000..443926353a
--- /dev/null
+++ b/.changeset/on-demand-network-first.md
@@ -0,0 +1,11 @@
+---
+'@tanstack/db-sqlite-persistence-core': minor
+---
+
+`initialRender: { strategy: 'network-first' }` now works for on-demand persisted Collections, including Electric's `progressive` mode. Previously the configuration threw `InvalidPersistedCollectionConfigError`.
+
+A subset load that settles online records persisted demand coverage for its exact demand. In a later session, a load of the same demand settles on its rows from SQLite once `networkTimeoutMs` (default 3 seconds) has passed, or at once if the network load fails. The network load keeps running, and its rows publish when they arrive. If it fails after that, the query stays ready. A demand that never settled online still waits for the network. Coverage proves that the rows were complete when they were loaded, not that they are still current. A source truncate, a snapshot transaction, scoped recovery, or a cache-generation rotation clears it. A load that a reset made obsolete, a load that was released before it settled, and a load during the rest of a scoped-recovery sync run record none. Coverage also ends when SQLite holds fewer rows for the demand than when it settled, for example after a source delete or Query DB retention expiry. A `refetch: true` load always waits for the network. An ordered window with a `limit` stays covered only while SQLite holds the same rows for it.
+
+A query that joins eager and on-demand sources becomes ready when every eager source is ready, which network-first allows from its restored rows after the deadline, and every demand the query reaches on an on-demand source has settled. A network failure alone does not settle an on-demand demand that is not covered.
+
+This applies only with the default single-process coordinator. With a multi-tab coordinator, such as `BrowserCollectionCoordinator`, on-demand loads still wait for the network.
diff --git a/.github/workflows/oracle-nightly.yml b/.github/workflows/oracle-nightly.yml
new file mode 100644
index 0000000000..b93bf13492
--- /dev/null
+++ b/.github/workflows/oracle-nightly.yml
@@ -0,0 +1,35 @@
+name: Nightly oracles
+
+# Longer oracle campaigns than PR CI can afford. The generated demand
+# coverage campaign runs with ten times its PR budget, and every hostile
+# mutant in scripts/demand-coverage-mutants.mjs must fail a named test.
+on:
+ schedule:
+ - cron: '17 6 * * *'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ demand-coverage:
+ name: Demand coverage campaigns and mutants
+ runs-on: ubuntu-latest
+ timeout-minutes: 240
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Setup Tools
+ uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3
+ - name: Run the generated campaign with a larger budget
+ env:
+ TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: '10'
+ run: pnpm --filter @tanstack/db-sqlite-persistence-core exec vitest run --coverage.enabled=false tests/persisted-demand-coverage-campaign-oracle.test.ts
+ - name: Run the demand coverage mutants
+ run: node scripts/demand-coverage-mutants.mjs
diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md
index e075073f38..912c806058 100644
--- a/docs/collections/electric-collection.md
+++ b/docs/collections/electric-collection.md
@@ -406,6 +406,14 @@ isolated but needs a separate cross-version cleanup policy while older tabs
may still use it. Scoped recovery
does not certify the new generation for offline reuse.
+With `initialRender: { strategy: 'network-first' }`, an on-demand or
+progressive Collection can render a demand offline from SQLite after that exact
+demand settled online in an earlier session. Each demand keeps its own
+coverage in the current persisted cache generation. Scoped recovery, cache-claim
+expiry, and a must-refetch end it, so after any of them a demand waits for the
+network again. A demand that loads during the rest of a scoped-recovery sync
+run records no coverage. See [On-demand Collections offline](../guides/sqlite-persistence.md#on-demand-collections-offline).
+
During scoped recovery, coordinator notifications about SQLite changes do not
hydrate, truncate, or replace public source rows. The Electric change stream
and demanded source snapshots own those rows until the sync run ends. A change
diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md
index 824ae70abd..6de5659ee2 100644
--- a/docs/contributing/glossary.md
+++ b/docs/contributing/glossary.md
@@ -61,7 +61,9 @@ production queues, caches, or semantic helpers merely to share their names.
| persisted readiness | The persisted wrapper's internal record that the current sync run's persisted restore completed or failed. Network-first initial render reads it; Collection status is the only public signal. | Collection readiness, network availability, or row nonemptiness. |
| persisted cache generation | One version of an on-demand Collection's durable rows and resume metadata, identified by a monotonic generation number and a distinct storage ID. One current generation accepts new claims; a retired generation remains available to sync runs that already claim it. A stale run may create a new retired generation for its own recovery without replacing the current one. Rotating a generation does not change the public Collection identity. | Sync run, provider session, or source snapshot. |
| persisted cache claim | A sync run's expiring authority to read and write one persisted cache generation. It is checked before durable access and is separate from a TanStack DB acquisition lease. | Acquisition lease, subset demand, or proof that cached rows are authoritative. |
-| network-first initial render | An opt-in persisted Collection setting, `initialRender: { strategy: 'network-first', networkTimeoutMs }`. The Collection waits for its sync adapter until the deadline passes or the remote fails, then a completed persisted restore that installed rows makes it `ready`. An empty restore keeps it `loading`. A remote failure before the restore completes waits for the restore. Network sync continues. | Collection readiness from the sync adapter, an SSR seed, or a separate status. |
+| persisted subset restore | The persisted 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 restore of an eager Collection, or proof that the rows are complete. |
+| 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, and how many rows the demand read then. It counts only while the persisted subset restore returns at least that many rows; for an ordered window, only while it returns the same rows. It is the durable, cross-session form of the coverage that a settled acquisition establishes in one sync run: when a covered demand settles from SQLite, it is covered in that sense too. It proves completeness when that load settled, not freshness or broader extent. | Rows present in SQLite, a broader or overlapping demand, or persisted readiness. |
+| network-first initial render | An opt-in persisted Collection setting, `initialRender: { strategy: 'network-first', networkTimeoutMs }`. The Collection waits for its sync adapter until the deadline passes or the remote fails, then a completed persisted restore that installed rows makes it `ready`. An empty restore keeps it `loading`. A remote failure before the restore completes waits for the restore. Network sync continues. An on-demand Collection instead settles each covered demand's load from SQLite once that load's wait passes or its remote load fails (persisted demand coverage); query readiness follows its loads. | Collection readiness from the sync adapter, an SSR seed, or a separate status. |
`source` names a role in a data flow; it does not own a lifecycle. Do not coin a
`source session`. Use `sync run` for the local Collection sync invocation
diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md
index afab073a85..278e912c5f 100644
--- a/docs/contributing/oracle-coverage.md
+++ b/docs/contributing/oracle-coverage.md
@@ -3778,6 +3778,204 @@ an answer, and a Collection that synced to zero rows before (it waits like an
empty restore) are outside it. Hooks read Collection status only, so the
framework conformance suite's status scenarios cover the binding side.
+The persisted demand coverage oracle in
+`packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`
+owns the on-demand law: an opted-in wrapper may settle a demand's
+load from SQLite only when that exact demand has persisted demand coverage,
+after the configured wait or an upstream failure. Its bounded enumeration
+crosses twelve session-1 histories (not requested, failed, overlapping
+filter, same filter with another window, same filter with another order,
+rejected durable row write, settled empty, settled, settled then truncated,
+three in which a reset lands after the load's rows but before it settles (a
+source truncate, a completed scoped recovery that rotates the cache
+generation, and a scoped recovery still pending), and one in which the source
+deletes a row of D after D settles), two waits, and six
+session-2 upstream schedules (pending, early and late failure, early and late
+answer, late empty answer), over the real wrapper and
+`SQLiteCorePersistenceAdapter` on `node:sqlite`. Session 1 creates coverage
+only through real loads, and a positive witness checks whether it sent the
+session-2 demand key. A pure trace model predicts status, `preload()` state,
+and exact rows at five checkpoints. A fixed lifecycle history checks that each
+load measures its own wait and settles on its own restored rows, that
+release aborts the upstream load, and that coverage survives release. A joined history checks that a
+query over an eager and an on-demand source is ready only when the on-demand
+demand is covered, read directly and through an outer live query, with the
+joined row visible when ready. A third shape reaches the on-demand source
+through two live queries; covering only the inner query's demand must keep
+the query loading.
+Two fixed same-session histories check that a load of D after a cache
+rotation, and a `refetch: true` load of D, both wait for the network. Model-level calibration shows that eighteen wrong designs (fifteen
+single-demand, two lifecycle, one joined) each differ from the law at a named
+case. Before the implementation, every
+production case failed at setup because `initialRender` rejected
+`syncMode: 'on-demand'`; without the opt-in, that code matched every
+uncovered cell and diverged at the first checkpoint where the law applies for
+every covered cell except an early network answer. With the implementation,
+every case passes; the [implementation review](oracle-reviews/2026-10-21-persisted-demand-coverage-review.md) records RED, GREEN, and each mutant's outcome. Production mutants that ignore coverage, ignore or halve
+the wait, error a covered demand on upstream failure, write coverage before
+the load settles, measure the wait from the sync run, keep coverage across a
+truncate, let an on-demand opt-in permit fallback alone, or read the root
+query's demands instead of the owning inner query's each fail named cases.
+A review then found three more defects, and the in-flight histories and the
+two-reader shape reproduced each one on unchanged production: an obsolete load
+recorded coverage after a truncate or rotation (22 cells), a coverage record
+during pending recovery failed the session-1 load (12 cells), and the first
+live query reached on a source stood for every reader (one cell). After the
+repair, mutants that restore each defect, or that check only for a truncate,
+fail those cases. A third review round added the row-deletion history, the
+source-failure history, and the rotation and refetch histories; on the parent
+production code they failed 14 cases by assertion, and the Query DB witness
+failed its retention history. Mutants that drop the row count, require more
+rows than recorded, ignore the cache generation, let a refetch settle on
+coverage, or let a network failure bypass on-demand settlement each fail
+those cases; a gate that ignores the
+eager deadline fails the persisted-readiness oracle instead. The Electric
+progressive witness in
+`packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts`
+receives the law as a production driver for the shared model: it records
+Electric's own log at its `loadSubset` boundary, and session 2 reads either
+the subset session 1 loaded or one no query read. A subset-only session 1
+settles the loaded subset offline and leaves the other waiting; after a
+completed progressive full sync, the never-loaded subset is ready too,
+because Electric settles every later load at once from its complete rows
+(model rule `full sync completes`). A mutant that drops the complete resume's
+full-snapshot resolution fails only that never-loaded history. The Query DB witness
+in `packages/query-db-collection/tests/persisted-on-demand-offline-witness.test.ts`
+receives it with a fresh QueryClient per session: a covered on-demand query
+stays loading until the wait and then shows its restored rows, a rejected
+`queryFn` settles it at once, and a never-loaded query keeps loading; a mutant
+that ignores coverage fails both covered histories. A fourth history lets
+Query DB retention delete the released rows after a 20 ms `gcTime`; session 2
+must then keep loading. React and Solid Suspense once received a joined
+initial-render gate through capability fixtures. The source-health change
+(#2168) removed that gate: Suspense now releases at query readiness, and the
+React and Solid receiving witnesses below run the shared contract. A later round closed most open limits with enforced cases. The driver checks
+that the wrapper forwards a load only after its persisted subset restore is
+visible, so no upstream event can precede it. Session 2 reads answer on a
+later macrotask, so a load that fails at once reaches the coverage read, and
+removing the wrapper's early rejection handler raises unhandled rejections.
+Part 2b adds a schema reset over an empty covered demand, an optimistic write
+that rolls back on a demand settled from SQLite, and two cache-claim expiry
+histories: one before the coverage record and one before the coverage check.
+Part 4 covers ordered windows: a row deleted, moved out, or moved in after the
+window settled keeps it loading, a swap keeps it covered, and with an index a
+page move settles offline only when session 1 loaded that page with a cursor.
+The Electric witness adds on-demand must-refetch and checks that Electric
+writes nothing from a snapshot that answers after release. React
+(`useLiveSuspenseQuery-persisted-on-demand.test.tsx`) and Solid
+(`useLiveQuery-persisted-on-demand.test.tsx`) Suspense render a covered demand
+from a real persisted on-demand Collection on `node:sqlite`. Vue
+(`useLiveQuery-persisted-on-demand.test.ts`), Svelte
+(`useLiveQuery-persisted-on-demand.svelte.test.ts`), and Angular
+(`injectLiveQuery-persisted-on-demand.test.ts`) witnesses observe `isReady`,
+`isLoading`, and rows. All five run the shared receiving contract described below. Mutants that drop
+the early rejection handler, keep collection metadata across a schema reset,
+key coverage without cursor and offset, ignore coverage, or let a coverage
+read under an expired claim fail the load each fail these cases.
+A fifth round added Part 2c and five more histories. Part 2c checks that a
+failed coverage read, after the load or during the check, keeps the load's
+own outcome; that a synchronous answer's coverage counts its rows, so a
+later deletion ends it; and that a release during the coverage check stops
+the source Collection loading. Part 3 then added a source failure before a
+covered demand's wait ends; after the source-health change (#2168) replaced
+the initial-render gate with Collection status, those histories belong to
+`packages/db/tests/source-health-oracle.test.ts`. Part 4 adds a row replaced below the window boundary and a row gained
+by an under-full window, which keep the tie-boundary key. Each failed on the
+parent production code. A window check that accepts a superset of the
+recorded keys, and a builder that drops its error-state check without
+notifying settlement, each fail named cases.
+A sixth round (the 2026-10-28 review) closed three more holes, each RED on
+the parent production code. The campaign adds a `snapshot` command (a
+snapshot transaction ends coverage, as a truncate does) and a source that
+stops and resolves an aborted load (a load that core aborted records
+nothing); Part 2b adds a load after scoped recovery reloaded the demand (no
+load settles or records during the rest of that sync run). The shared model
+now judges demand identity from the request data itself instead of core's
+demand key. The storage sweep also checks that a failed coverage write
+crashes the Collection.
+
+Remaining limits: coverage relies on the source committing a load's rows
+before it settles that load (law 7); an unordered demand that loses one row
+and gains another keeps its row count and stays covered, so a row that
+retention removed can be missing from a covered restore (maintainer
+decision, 2026-10-26; a fixed history pins the rule); and multi-tab
+coordinators, outside the law by maintainer decision (2026-10-26). Part 4
+now covers a tie at the window boundary, modelled as a limited demand plus
+an unordered tie-boundary demand, and page moves with and without an index.
+
+The generated campaign in
+`packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts`
+shares the fixed oracle's driver (`tests/helpers/demand-coverage-driver.ts`).
+Its model replays the source's own log of row writes, deletes, truncates,
+and settled loads, so it does not predict when the wrapper reacquires a
+demand. A fixed and a random campaign of 40 histories each interleave two
+unordered demands with load starts, answers, failures, releases, live
+writes and deletes, truncates, snapshot transactions, and a source that
+rejects, ignores, or stops and resolves an aborted load; declared reach
+covers an obsolete load that settles, an aborted load that settles, a demand
+settled only by an aborted load, coverage ended by a snapshot, coverage
+recorded twice, a row moved between demands, and a covered demand short of
+rows. Part 2 fails each
+adapter call of a covered history once, before or after its effect, in
+either session: safety holds for every call, a failed coverage read keeps
+the load ready, and a failed coverage check waits. Part 3 releases the
+demand after each step of a load: the source Collection stops loading, and
+every load left behind was aborted. Mutants that ignore the row count, let
+an obsolete load record coverage, keep coverage across a truncate, let a
+failed coverage read fail the load, or keep a released load's wait each fail
+it. The campaign also reads the window `group = 'a' orderBy rank limit 2`
+over generated ranks with ties: a query is ready offline exactly when every
+load it sent in session 2 is covered, which judges the tie-boundary load
+without predicting when core sends it. Mutants that ignore window keys,
+compare them in order, or record keys for every demand each fail it. Its
+limits: real timers in session 1, one process, `node:sqlite`, and no cursor
+or offset loads.
+A scripted mutation sweep (63 mutants, recorded in the review record) then
+added five fixed histories: an empty covered demand after a rotation, a
+truncate during an empty load's coverage read, a synchronous answer whose
+durable write lands late, claim recovery after an expired claim during the
+check, and a coverage read that outlasts the wait. A sixth history holds the
+load's cache-claim renewal past the wait and kills the last survivor, a wait
+measured from after that renewal. No survivor of the sweep remains open in
+this owner.
+
+Every receiving witness is a production driver for the same judgment
+(ORC-014). The shared contract,
+`packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`,
+holds six histories as data (session-1 steps and session-2 premises only):
+covered, never loaded, covered then a source delete, a covered window, a
+window with a row replaced below the boundary, and a covered demand whose
+upstream fails at once. Each host records the source log, the session-2 loads,
+and two checkpoint observations, and the shared model in
+`tests/persisted-demand-coverage-model-oracle.ts` judges them; no witness
+states an expected result. The histories module imports nothing, so the
+Playwright spec can load it.
+
+The hosts run all six through their own entry point: Node (better-sqlite3
+through `createNodeSQLitePersistence`), Chromium OPFS (wa-sqlite in two page
+loads, in `playwright.opfs.config.ts`, which the browser E2E job runs),
+Electron (a renderer over IPC to a `node:sqlite` main process, two app
+launches, in the desktop E2E job), Cloudflare Durable Objects (`wrangler dev`
+restarted over one persist directory, in the cloud-mobile job), React Native
+(an op-sqlite-shaped driver, in the cloud-mobile job), Expo (an expo-sqlite
+shim), and Tauri and Capacitor (their unit-suite SQLite stand-ins). The React,
+Solid, Vue, Svelte, and Angular witnesses run the covered, never-loaded, and
+failure histories through their own hook or primitive. With the contract's
+session-2 source forced to a 60 s wait and an upstream that never answers,
+every host and framework reports violations on its covered histories. The
+OPFS witness also pins the guide's statement that a multi-tab coordinator
+waits for the network; that run is recorded, not judged, because it pins the
+guide rather than the law.
+
+Limits: no CI job runs the Expo, Capacitor, or Tauri e2e suites, and no host
+runs on its native runtime here. The Capacitor device e2e needs an iOS
+simulator or Android emulator, and `adb` is not installed here; its Node e2e
+fails at file load (`SqliteError: cannot start a transaction within a
+transaction` at `tests/helpers/capacitor-sqlite-test-db.ts:22`). The Tauri
+native e2e needs `cargo`. React Suspense reveals a covered failure about 300 ms
+late (its fallback throttle), so the contract's before-wait checkpoint sits at
+450 ms.
+
## Sync metadata session
[Issue #2220](https://github.com/TanStack/db/issues/2220) reports that a
diff --git a/docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md b/docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md
new file mode 100644
index 0000000000..0218c7d260
--- /dev/null
+++ b/docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md
@@ -0,0 +1,370 @@
+# 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:
+
+| Upstream | Local subset | Query rows | Query ready |
+| --- | --- | --- | --- |
+| never settles | complete | `a1, a2` | no |
+| never settles | partial | `a1` | no |
+| never settles | empty | none | no |
+| settles at once, no write | complete | `a1, a2` | yes |
+| settles at once, no write | empty | none | yes |
+
+`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 1 | Session 2 resumes stream | Query rows | Query ready |
+| --- | --- | --- | --- |
+| completed full sync | yes (`10_0`, `shape-1`) | `1, 2` | yes |
+| loaded the query subset only | no | `1, 2` | no |
+| nothing | no | none | no |
+
+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.
diff --git a/docs/contributing/oracle-reviews/2026-10-21-offline-on-demand-explainer.html b/docs/contributing/oracle-reviews/2026-10-21-offline-on-demand-explainer.html
new file mode 100644
index 0000000000..fafe962260
--- /dev/null
+++ b/docs/contributing/oracle-reviews/2026-10-21-offline-on-demand-explainer.html
@@ -0,0 +1,658 @@
+
+
+
A Discord user turned on persistence. initialRender: network-first worked for eager collections but not for progressive Electric ones. They expected progressive to render from SQLite without waiting for the network.
+
Why it fails
Electric reports progressive to core as on-demand, and the persisted wrapper rejects network-first for any on-demand source. Not Electric-specific.
+
What we measured
Cached rows already show while offline. Only the ready signal waits. A completed progressive sync already works offline; anything short of that waits forever.
+
Where we landed
One rule: the wrapper may settle a load from SQLite when that exact query finished loading before. Everything downstream follows from existing laws.
+
+
+
+
+
+
How readiness works today
+
A live query is ready when every source gives it evidence that its rows are present. Since #2115, that evidence depends on the source's sync mode.
+
+
+
+ EAGER SOURCE
+
Collection readiness is the evidence
+
It loads everything without per-query requests, so the query waits for the Collection to be ready. With persistence, a finished SQLite restore of the whole Collection is a separate signal. Network-first (#1955) lets Suspense render that restore after a deadline.
+
+
+ ON-DEMAND SOURCE
+
A settled load is the evidence
+
Each query asks for its subset. The persisted wrapper reads that subset from SQLite, then waits for the upstream load. The query is ready when both settle. There is no per-query "restored" signal, so network-first is rejected.
+
+
+
Progressive Electric is on-demand to core. Its Collection stays loading until the full sync, but its queries go ready as their own loads settle.
+
+
+
+
+
Try it
+
Pick a source, what happened last session, and what the app asks for now. Then compare today's behavior, a naive fix, and the proposed rule. Times are illustrative: SQLite answers at 120 ms, a live network at 900 ms.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SQLite
+
Network
+
Rows on screen
+
Query ready
+
+
+
+
+
+
Rows visible–
+
isReady / Suspense–
+
Is the answer correct?–
+
+
+
+
+
+
+
+
What we measured
+
Two witness tests record current behavior on main. They pin observations, not a contract. Each checkpoint is 50 ms after the query starts.
+
+
Generic on-demand source
+
+
Upstream
Local subset
Rows visible
Query ready
+
+
Never answers
Complete
a1, a2
no
+
Never answers
Partial
a1
no
+
Never answers
Empty
none
no
+
Settles at once, writes nothing
Complete
a1, a2
yes
+
Settles at once, writes nothing
Empty
none
yes
+
+
+
Progressive Electric, two sessions on real SQLite, second session offline
+
+
Session 1
Session 2 resumes stream
Rows visible
Query ready
+
+
Completed full sync
yes
1, 2
yes
+
Loaded the query subset only
no
1, 2
no, forever
+
Nothing
no
none
no
+
+
+
The wrapper can't tell a complete subset from a partial or never-loaded one; they all just read back as rows. That's why "show whatever SQLite has" is wrong.
+
+
+
+
The model we're exploring
+
+ The one rule
+
The persisted wrapper may settle a query's load from SQLite once the local rows are visible, if that exact query is covered in the current cache: a previous load of it finished, or the source holds a complete baseline. The network load keeps running and replaces the rows when it answers.
+
+
Exact query keyMatches by getLoadSubsetDemandKey. No "this query is broader than that one."exists today
+
Covered setPer cache generation: keys whose load finished, including empty results, plus "complete baseline" which matches everything.new, in the wrapper
+
Settle ruleSettle when local rows are visible and the query is covered and the wait has passed or its load failed.new
+
Network continuesUpstream keeps loading; its rows publish normally when they arrive.exists today
+
+
Why it's small: initial readiness, Suspense, queryOnce, skipInitial effects, and ordered-window publication (pagination) all already wait on "this query's load settled." Settle a covered load and every one of them works offline with no live-query changes. ARCHITECTURE.md already says a source that resumes from complete local rows "settles its loads at once." The rule moves that idea from the whole Collection down to one query.
+
The network-first wait turns into an optional knob. No correctness property needs it. At 0 it matches how a completed progressive resume behaves today.
+
+
+
+
+
+
What the rule settles
+
The first draft had nine open decisions. Most went away once the design reduced to the single rule.
+
+
+
Earlier open decision
Under the one rule
+
+
Where coverage lives
The wrapper, because it has to work the same for every source
+
loading vs unavailable status
Goes away; on-demand needs no new persisted status
+
Progressive subsets before the full sync
Covered if their load settled (inferred, needs a witness)
+
Deadline meaning per query
One wait, measured from each load's start; that load's failure ends it
+
"Not available offline" status
Exposed to the in-progress precise-errors PR
+
Page keys built from other rows
A known limit: if restored page 1 differs, page 2 is a miss and waits for the network
+
Publishing then replacing rows
Already normal for ready queries
+
API shape
One wrapper option: on/off plus wait
+
Oracle owner
The wrapper's settle rule; existing live-query oracles own the consequences
+
+
+
Decisions since the first pass
+
+
1
Coverage lifetime. Valid until revalidated or until the cache generation ends. A persistedGcTime-style expiry gets its own issue and waits until someone asks for it.
+
2
The wait. Reuses the network-first option and its 3 s default from #1955; 0 is valid.
+
3
Mixed joins. On-demand sources register no persisted readiness, so a query that joins an eager source keeps the ordinary readiness path and waits for the eager network offline. That's safe, so it's recorded as a limit.
+
4
Law and ARCHITECTURE.md. Both are updated in the worktree, along with two glossary terms.
+
+
Not done yet: no formal model check, no independent range case, and Electric's own pending load after the wrapper settles is unexamined.
+
+
+
+
+
How we got here
+
Earlier work that shaped this problem. Filled dots are the ones that matter most.
+
+
+
2025-11-05 · #618Electric maps progressive to on-demandCore only ever sees eager or on-demand.
+
2026-03-20 · #1380 (samwillis)Persisted sync metadataQuery DB stores per-row query owners and persistedGcTime retention, "for offline-first flows."
+
+
+
+
+
+
+
diff --git a/docs/contributing/oracle-reviews/2026-10-21-persisted-demand-coverage-review.md b/docs/contributing/oracle-reviews/2026-10-21-persisted-demand-coverage-review.md
new file mode 100644
index 0000000000..897dcbd415
--- /dev/null
+++ b/docs/contributing/oracle-reviews/2026-10-21-persisted-demand-coverage-review.md
@@ -0,0 +1,676 @@
+# Persisted demand coverage: implementation review
+
+Reviewed head: `cd2aceaec858e0d090fa330d67a9d511c51ba5da`.
+Implementation commits: `4fecdc75835ea7b706772b88e9c02caaa60f5caf` (production
+change), `42e15a33fc0a7cff5e569bb00f60d3758035b5a2` (truncate history).
+RED head: `85372868c05ed933a2234fcf7c5365aa281e48d9` (oracle before the
+production change). Merge base with `main`:
+`9aecbab4cacb3f1123b1716e6f063abb5affc74f`.
+
+The law, the oracle design, and the wrong designs are in
+[the law record](2026-10-20-on-demand-network-first-law.md). This record
+contains the evidence obtained against the implementation.
+
+## Law and result
+
+An opted-in persisted wrapper over an on-demand source may settle a demand's
+load from SQLite when the persisted subset restore of that demand is visible,
+the exact demand key has persisted demand coverage in the current cache
+generation, and the wait since the load started has passed or the upstream
+load has failed. The upstream load keeps running. Coverage survives release
+and ends at a source truncate. For network-first initial rendering, an eager
+source permits local rendering after its restore and deadline; an on-demand
+source permits it when the query's demands on it have settled.
+
+Authority: the maintainer decisions recorded in the law record (exact keys, the
+same wait option and default as eager, joined sources combine, a late failure
+leaves the query ready, coverage written through the source-transaction
+pipeline). `packages/db/src/query/live/ARCHITECTURE.md` (the persisted-wrapper
+paragraph, the initial-readiness limit, and law 7) states the same contract.
+
+Production path: `persistedCollectionOptions` over a generic on-demand source
+and `SQLiteCorePersistenceAdapter` on `node:sqlite`. The wrapper's
+`PersistedCollectionRuntime.loadSubset` and `loadHydratedSubset` settle a
+covered demand. The wrapped `loadSubset` records coverage after the source
+load in a metadata-only source transaction. The wrapped `truncate` deletes
+coverage keys in its own transaction. In core, `CollectionConfigBuilder`
+exposes `initialDemandsSettled()` and notifies subscribers from
+`updateLiveQueryStatus`, and `LiveQueryObserver.preloadForInitialRender`
+combines the sources.
+
+## RED and GREEN
+
+RED, at `85372868c` with the 96-cell grammar: every production case failed at
+setup with `InvalidPersistedCollectionConfigError`. A probe without the opt-in
+(recipe in the law record) passed all 72 uncovered cells. It failed 22 of 24
+covered cells at the first checkpoint where the law applies; the two
+early-network-answer cells pass under both designs. The lifecycle case and the
+covered joined case failed, and the uncovered joined case passed.
+
+GREEN, at the reviewed head: the coverage oracle passes all 139 tests (108
+cells, the lifecycle history, two joined histories, and calibration). Also at
+this head, `packages/db` passes 13,422 tests, `db-sqlite-persistence-core`
+passes 1,327, and `electric-db-collection` passes 719. The type errors at
+`persisted-oracle.test.ts:4730` and `:4871` also occur with this change's
+sources reverted, so this change does not cause them.
+
+Two driver repairs reached production paths for the first time. Neither
+changed an expected result:
+
+- Session 1 attached its rejection handler to `preload()` only after the
+ answer loop, so the `failed` history produced unhandled rejections.
+- The joined comparison included virtual row properties (`$key`, `$synced`,
+ ...), which the law does not govern.
+
+## Production mutants (ORC-006)
+
+Each mutant edited one production line. The script lives outside the
+repository. Every kill is an assertion failure at a refinement comparison. No
+mutant failed at setup, at a `checkpoint not reached` witness, or by timeout.
+
+| Mutant | Outcome |
+| --- | --- |
+| Coverage ignored (always wait for upstream) | Killed: 24 cells, first `settled-empty, wait 0, pending` at `restored` |
+| Wait ignored (settle at restore) | Killed: 13 cells, `wait 1000` covered cells at `restored` |
+| Half deadline | Killed: 9 cells at `beforeDeadline` |
+| Failure errors a covered demand | Killed: `settled` and `settled-empty`, `wait 1000, fails-early` |
+| Coverage recorded before the upstream load settles | Killed: 33 cells, first the `failed` history |
+| Deadline from the sync run, not the load | Killed: lifecycle history at checkpoint 1000 |
+| Truncate keeps coverage | Killed after `42e15a33f`: 11 `truncated` cells; it survived at `4fecdc758`, before that history existed |
+| On-demand opt-in permits fallback alone | Killed: joined `never requested` |
+| Gate ignores the eager deadline | Survives this oracle (Part 3 uses a 0 ms eager wait); killed by `persisted-readiness-oracle.test.ts` ("does not hide a query failure behind an available persisted source") |
+
+The "coverage recorded before the upstream load settles" mutant is coarser
+than the wrong design it stands for: it records coverage for a load that later
+fails, not only for one whose rows fail to persist. The model-level
+`durable-write-failed` calibration and the rejecting adapter keep the narrower
+design distinguished. No production mutant that writes coverage through a
+separate durable path, outside the source-transaction pipeline, was run.
+
+## Requirement outcomes
+
+- **ORC-001:** The opening prose names the law, its sources, and its limits.
+ Truncate moved from the limits into the grammar.
+- **ORC-002:** The model reads only the history label. It never reads SQLite,
+ coverage keys, or the wrapper. The positive witness compares session-1 and
+ session-2 keys through `getLoadSubsetDemandKey`. That is a trusted base for
+ the witness only; it does not compute any expected result.
+- **ORC-003:** The contract, grammar, model, driver, and refinement check are
+ separate, and each has prose beside it. The `truncated` history has its own
+ prose and ablation note.
+- **ORC-004:** Not triggered; the grammar is a bounded enumeration, not
+ generated. The file still records ablation for each history, wait, and
+ schedule value, and the excluded pre-restore event.
+- **ORC-005:** A positive witness checks session-1 demand keys, and a
+ checkpoint witness checks the restored rows. For `truncated`, another witness
+ checks that the truncate replayed D and that the load stays pending. No
+ missing-information hypothesis was identified; this is not a claim of an
+ exhaustive search.
+- **ORC-006:** Sixteen model-level wrong designs (thirteen in Part 1, two
+ lifecycle, one joined), each distinguished at a named case, and the
+ production mutants above.
+- **ORC-007:** Not triggered; there is no generated property.
+- **ORC-008:** The model keeps four facts: covered, visible rows, settled, and
+ failed. Each one changes a later observation, as the model prose explains. The
+ `truncated` history adds no state; it maps to "not covered" with no rows.
+- **ORC-009:** The glossary terms are persisted subset restore, persisted demand
+ coverage, and load settlement, and the model prose maps them.
+- **ORC-010:** `withCleanup` keeps a primary failure and cleanup failures apart
+ in one `AggregateError`, and a calibration test checks this.
+- **ORC-011:** No shared-fault hypothesis was named. Demand-key identity is the
+ candidate: the oracle trusts `getLoadSubsetDemandKey` to tell the session-1
+ demands apart. A second formulation that derives keys independently was not
+ added.
+- **ORC-013:** The wait threshold has checkpoints at `wait - 1` and `wait`, and
+ the lifecycle history has checkpoints ±1 ms around each load's own deadline.
+ The half-deadline and sync-run-deadline designs are rejected there.
+- **ORC-014:** The controlled premise is a source whose load settles after its
+ applied receipt. Two receiving witnesses supply it with a real source. In
+ the Electric progressive witness, subset-only coverage settles offline and a
+ never-loaded subset waits. In the Query DB witness
+ (`persisted-on-demand-offline-witness.test.ts`, added after the reviewed
+ head), a covered query settles at the wait or at once on a rejected
+ `queryFn`, and a never-loaded query waits. The coverage-ignored mutant fails
+ both covered Query DB histories.
+
+## Open in-scope cells
+
+The [coverage map](../oracle-coverage.md#persisted-readiness-and-network-first-initial-rendering)
+owns these. Each needs a witness:
+
+- ordered windows and cursor pages in session 2, including a key that ignores
+ offset or cursor;
+- must-refetch, schema reset, and cache-claim rotation in the coverage oracle;
+ Electric must-refetch goes through `truncate`, but no Electric history
+ exercises it;
+- a failure that arrives before the persisted subset restore publishes;
+- optimistic writes on a covered demand;
+- nested live queries beyond one outer query over the joined query. After
+ the reviewed head, Part 3 adds that nested shape and reads rows at release;
+ a gate that reads the root query's demands fails only the nested cells.
+- Suspense over a real persisted on-demand Collection. After the reviewed
+ head, React and Solid tests receive the joined gate with capability fixtures,
+ and the gate mutant that lets the on-demand opt-in permit fallback alone
+ fails both.
+
+This record claims a new law, not the closure of a bug class.
+
+## 2026-10-26: code-review findings after merging `main`
+
+Merge head: `5c0e04dce` (merges `main` at `5649a07d4`; the network-first wait
+now lives in `waitForPersistedInitialRender`, and the on-demand gate moved
+into it). Oracle extension: `e4add5b18`. Repair: the next commit.
+
+A code review reported ten findings. Four were defects under the accepted
+contract, one needed a product decision, and the rest were refuted or a cost
+only. The oracle was extended first and run against unchanged production:
+
+- three session-1 histories put a reset after a load's rows and before it
+ settles: a source truncate, a completed scoped recovery that rotates the
+ cache generation, and a scoped recovery still pending;
+- Part 3 adds a shape that reaches the on-demand source through two live
+ queries, with a history that covers only the inner query's demand.
+
+RED on `5c0e04dce`: 35 failed and 137 passed of 172, all by assertion at a
+named checkpoint. In 22 cells an obsolete load recorded coverage. In 12 cells
+a coverage record during pending recovery failed the session-1 load. The
+two-reader inner-only history released the wait.
+
+Repair: a load records coverage only if no lifecycle change, reset (rotation
+or scoped recovery), or source truncate happened since it was forwarded
+upstream. The observer gives an on-demand source one gate entry for each live
+query that reads it. Because scoped recovery marks a reset before it can abort
+the coverage commit, the epoch check also closes the failed-record case; a
+`.catch` on the commit had no reachable history and was removed.
+
+GREEN: 172 of 172. Also `db` 13,462, `db-sqlite-persistence-core` 1,535,
+`react-db` 601, and the Electric and Query DB witnesses pass. Solid did not
+run: its test environment needs a package that the registry proxy blocks.
+
+Mutants, all killed by assertion with no timeout or unreached checkpoint:
+
+| Mutant | Outcome |
+| --- | --- |
+| Restore the original: any settled load records coverage | 34 cells fail across all three in-flight histories |
+| Narrow fix: check only for a source truncate | 23 cells fail in the two scoped-recovery histories |
+| Restore the original gate: the first live query reached on a source stands for all | The two-reader inner-only history fails |
+
+By maintainer decision (2026-10-26), multi-tab coordinators are outside the
+law: under a coordinator other than `SingleProcessCoordinator`, demand routes
+through the coordinator, so a covered demand neither settles from SQLite nor
+records coverage. A probe showed a covered demand staying loading offline even
+in the leader tab. The coverage map, law record, architecture paragraph, and
+guide state the limit.
+
+Refuted: scoped recovery and reload truncates keep coverage (they refresh
+memory only; rotation starts a fresh storage target and schema reset deletes
+collection metadata); a raw on-demand root releases early (granular mode waits
+for its load; wholesale mode requests no demand online or offline). Cost only,
+no law: a source's literal `true` becomes a promise, and each load writes one
+metadata record. Accepted: coverage keys grow until a truncate or expiry
+(#2211).
+
+## 2026-10-26: CodeRabbit and xhigh reviews of PR #2263
+
+Reviewed head: `3de5a922f` (CI green). CodeRabbit review `5655576117` gave five
+findings (CR1-CR5) and an xhigh code review gave fifteen (X1-X15). The oracle
+was extended first and run against the parent's production code. Maintainer
+decisions: Query DB retention expiry ends coverage, through a row-count check
+that also covers deletes in a limited window; the certified-baseline clause
+is dropped; multi-tab coordinators stay out of scope.
+
+Oracle and witness changes:
+
+- Part 1 adds `settled-then-row-deleted`, and its wrong design "coverage
+ ignores rows deleted after it settled".
+- Part 2b adds two same-session histories: a load of D after a cache rotation,
+ and a `refetch: true` load of D. Both must wait for the network.
+- Part 3 adds a source failure on a never-covered demand, which must not
+ release the wait.
+- The Query DB witness adds a retention-expiry history.
+- The Electric witness driver now serializes work, as the driver contract
+ requires.
+
+RED on the parent production code (`3de5a922f`): the oracle failed 14 of 188
+(11 row-deletion cells, and the refetch, rotation, and source-failure
+histories), and the Query DB witness failed its retention history. All
+failures were assertions at a named checkpoint.
+
+Repair: a coverage record stores the cache generation's storage ID and the
+number of rows the demand reads from SQLite. It counts only for the current
+generation and while the restore returns at least that many rows. A load
+rewrites the record only when that count changes. A `refetch` load never
+settles from coverage. An aborted load clears its wait timer. The gate checks
+on-demand settlement on the network-failure path too, and the observer keeps
+one entry for each source and owning live query.
+
+GREEN: the oracle passes 188 of 188 and the Query DB witness 4 of 4. Also:
+`db` 13,462, persistence 1,415, `react-db` 601, `solid-db` 99,
+`electric-db-collection` 719, `query-db-collection` 929. Size against `main`:
+`app` +78 B gzip, `db-sqlite-persistence-core` +475 B gzip.
+
+Mutants, all killed by assertion:
+
+| Mutant | Outcome |
+| --- | --- |
+| Drop the row count | 11 row-deletion cells and the Query DB retention history fail |
+| Require more rows than recorded | 28 covered cases fail |
+| Ignore the cache generation | The rotation history fails |
+| Let a refetch settle on coverage | The refetch history fails |
+| Let a network failure bypass on-demand settlement | The source-failure history fails |
+
+Counters, not oracle cases: five loads of one covered demand now cause one
+coverage commit, not five. Releasing a covered load leaves no settlement timer
+behind, while the unfixed code keeps one.
+
+| ID | Verdict | Action |
+| --- | --- | --- |
+| CR1, X11 | Confirmed: the glossary entry claimed demand settlement | Fixed: the entry now names the source status only, and the network-first entry carries the demand rule |
+| CR2 | Confirmed | Fixed: the law record states its implementation and the query-level signal |
+| CR3 | Confirmed | Fixed in the guide |
+| CR4, X4 | Confirmed (RED) | Fixed |
+| CR5 | Confirmed by reading the driver | Fixed |
+| X1 | Confirmed (RED) | Fixed by the row count |
+| X2 | Confirmed (RED) | Fixed by the generation check |
+| X3 | Confirmed (RED) | Fixed |
+| X5 | Refuted: a truncate clears hydrated demand, so the later load must hydrate, and the wrapper rejects a source transaction that crosses a hydration cycle | Recorded as an excluded, illegal history in the oracle |
+| X6 | Confirmed by counter | Fixed for writes; each load still reads its row count, and a synchronous `true` result settles asynchronously |
+| X7 | Not reproduced; read from the code | Open in the coverage map |
+| X8 | Confirmed by reading; deletes are now reached through `settled-then-row-deleted` | Fixed by the row count for deletes; an order change without a delete stays open |
+| X9 | Confirmed by counter | Fixed |
+| X10 | Confirmed by reading | Fixed: one entry for each source and owner |
+| X12 | Accepted as the design direction | The generation and the row count replace per-path invalidation, except the truncate deletion that empty demands still need |
+| X13 | Confirmed | Fixed: the observer reuses the exported type |
+| X14 | Confirmed | Partly fixed: the eager deadline is read once per source; the demand key is still computed more than once per load (cost only) |
+| X15 | Confirmed | The clause is dropped from the law record and the glossary |
+
+## 2026-10-26: second CodeRabbit review
+
+Reviewed head: `f62f9dc19`. CodeRabbit review `5656501808` gave two findings.
+
+| ID | Verdict | Evidence | Action |
+| --- | --- | --- | --- |
+| CR6 | Confirmed | A real-timer probe with a driver that answers reads on a later macrotask: a load that rejects as soon as it is forwarded raised one `unhandledRejection` during the coverage read, and none after the fix | Fixed: `settleCoveredDemand` observes the upstream rejection before the read |
+| CR7 | Confirmed (RED) | The new Part 2b history "keeps a synchronous answer for an already hydrated demand synchronous" failed by assertion: the second request reported a promise | Fixed: a literal `true` returns unchanged, and its coverage record follows without holding the load |
+
+The oracle adds the session-2 schedule `fails-at-once`. Its driver answers
+SQLite reads within the same microtask turn, so it reaches the failure path but
+cannot hold the rejection across the coverage read. Under the fake clock, a
+driver that delays reads with a timer stopped session 2 before its first
+checkpoint, so CR6 keeps the probe as its witness. GREEN: the oracle passes
+215 of 215, persistence 1,442, Electric 719, and Query DB 929.
+
+The CI failure on `f62f9dc19` in `ordered-work-oracle.property.test.ts` is
+issue #2221. Its seed `1535602160` fails the same way on `main` at
+`5649a07d4`, in the core ordered loader. The fixed-seed case of that run timed
+out under CI load and passes locally in about one second.
+
+## 2026-10-26: closing open limits and the expired-claim finding
+
+Starting head: `5ba90763d` (CI green). The maintainer asked to close the open
+limits and then evaluate CodeRabbit review `5657028264` (CR8: a coverage read
+under an expired cache claim fails a load whose rows applied).
+
+Test-only commit `0949bc478` turned open limits into enforced cases, with no
+production change. Each passed on the existing code except where noted:
+
+- the restore-before-forward invariant;
+- macrotask reads in session 2, which put CR6 inside the oracle (removing the
+ early rejection handler raises six unhandled rejections);
+- a schema reset over an empty covered demand (killed by a mutant that keeps
+ collection metadata);
+- an optimistic write that rolls back on a demand settled from SQLite;
+- ordered windows (Part 4): row deleted, moved out, moved in, swapped, and
+ page 2 with an index (killed by a coverage key that ignores cursor and
+ offset);
+- Electric on-demand must-refetch, and Electric's own load after release;
+- React and Solid Suspense over a real persisted on-demand Collection (each
+ killed by a mutant that ignores coverage).
+
+An on-demand Electric mock that ended a subset with `snapshot-end` instead of
+`subset-end` let Electric settle its load before its rows committed. Coverage
+then recorded zero rows, and the offline session went ready on `[]`. The SDK
+ends injected subsets with `subset-end`, so the mock now does. The law record
+states the dependency on source settlement.
+
+CR8, confirmed on two paths. RED on the parent production code: the session-1
+query went to `error` when the claim expired before the coverage record, and
+D went to `error` when it expired before the coverage check. With coverage
+recording disabled, the first history stays `ready`, so the coverage read
+caused it. Repair: a coverage read that fails under an expired claim returns
+no count and schedules claim recovery; other failures propagate. GREEN: the
+oracle passes 227 of 227.
+
+X7 stays open: a probe that queued an empty load's coverage record during
+another demand's hydration, truncated, and started scoped recovery left the
+Collection `ready`, but it did not prove that the record was queued when the
+truncate listed metadata.
+
+
+## 2026-10-26: high code review after the shared test driver
+
+Starting head: `5d85ce247` (CI green). A high code review reported nine
+findings, H1 to H9. The oracle was extended for every behavioral finding before
+any production change, and each extension was run on the starting head.
+
+| Finding | Law and authority | Oracle change | RED on `5d85ce247` | Repair |
+| --- | --- | --- | --- | --- |
+| H1: a source error leaves the network-first wait pending forever when the query reaches an on-demand source | The glossary's network-first gate permits a render once every eager source passed its deadline or saw the network fail and every on-demand demand settled. A source failure does not settle a demand and does not block a settled one. | Part 3: P or C reports an error before C's 1000 ms wait ends, observed before the wait and after a late checkpoint. The observation adds rejection and the observer's synchronous gate. | Both covered histories: not released, not rejected; the never-requested histories pass | `initialDemandsSettled` no longer reads the error state, which still blocks `markReady`. `settleDemand` notifies settlement listeners, because a query in error runs no graph and so no status check. |
+| H2: a failed coverage read fails the load | Prior behavior: a load settles as its upstream load did. The law record makes coverage bookkeeping. | Part 2c: the read after the load fails; the read during the coverage check fails. | Session-1 query `error`; D `error` before the network answers | Any failed coverage read returns no rows. An expired claim also starts claim recovery. |
+| H3: a synchronous answer's coverage counts rows before they are durable | Law: coverage records the rows SQLite holds for the demand when the load settles, so a later deletion ends it. | Part 2c: a synchronous answer, with and without a later deletion of one row. | With the deletion, D settled offline | The coverage read waits on the apply mutex behind every committed source transaction. |
+| H4 (= X7): the truncate's metadata list makes a pending coverage transaction fail-stop on abort | — | None: not reachable | — | A coverage transaction has no abort signal. Its only abort is `SyncTransactionAbortedError` after its lifecycle was replaced, and the fail-stop then marks a terminal failure on the replaced lifecycle generation, which `getCurrentTerminalFailure` ignores. X7 is closed. |
+| H5: the row count accepts a window that lost one row and gained another | The approved window law (maintainer decision, 2026-10-26): a window settles only while its restore holds the same rows. | Part 4: `row-replaced` (`a1` replaced below the boundary) and `row-gained-under-limit` (an under-full window gains a row). Neither changes the tie-boundary key. | Both settled from SQLite | Coverage of a limited load records its sorted row keys, and the restore must return the same keys. |
+| H6: a release during the coverage check leaves the wait's timer running | Law record: a released load owns no wait. | Part 2c: release while the coverage read is held. | The source Collection still loaded after release | `settleCoveredDemand` returns at once when the signal is already aborted. |
+| H7: a second SQLite read per load | Cost, not behavior | None | — | Kept. The read now also waits on the apply mutex. Reusing the restore read for the check is a possible optimization. |
+| H8: duplicated builder-or-Collection lookup | Maintainability | None | — | Declined: a shared helper does not reduce shipped bytes. |
+| H9: settlement listeners fire only from the status check | — | None: not reachable | — | `retireDemand` runs only for an empty demand update inside a graph run, which ends with a status check. Setup sends only nonempty initial keys. `settleDemand` now notifies directly (H1). |
+
+The unordered part of H5 was a law question, not an enforcement gap: the
+approved row-count rule accepts an unordered demand that lost one row and
+gained another. The maintainer kept the row count for unordered demands
+(2026-10-26) over recording every row key. A fixed history now pins that
+choice: an unordered demand that loses `a1` and gains `a3` settles offline
+on `a2` and `a3`. A mutant that records keys for every demand fails it.
+
+Hostile mutants, beyond the starting head:
+
+- a window check that accepts any superset of the recorded keys survives
+ `row-replaced` and is killed by `row-gained-under-limit`;
+- dropping the error-state check without notifying from `settleDemand` is
+ killed by both covered source-failure histories.
+
+GREEN: the oracle passes 262 of 262 with the unordered history (vitest counts its type checks too).
+
+## 2026-10-26: strengthening the oracle beyond the review findings
+
+Starting head: `4293b0021`. The maintainer asked for seven strengthening steps.
+None changed production; each was checked with mutants against the current
+code.
+
+- **Window limits** (`34642864a`): Part 4 adds four histories with a tie at
+ the window boundary and runs both page-move histories without an index.
+ The model treats the window as a limited demand (same rows) plus an
+ unordered tie-boundary demand (row count). A mutant that ignores the
+ tie-boundary row count fails `tie-row-outside-deleted`; one that records
+ keys for every demand fails `tie-row-added`.
+- **Generated campaign** (`b9a5e481d`): a fixed and a random campaign of 40
+ histories each. The first model predicted when the wrapper reacquires a
+ demand after a truncate, and three counterexamples showed that this
+ scheduling differs from the guess (the wrapper reacquires a demand only
+ after its obsolete load settles, and then reacquires every active
+ demand). The law leaves that timing open, so the model now replays the
+ source's own event log and judges each settled load as current or
+ obsolete by whether a truncate came between its arrival and its
+ settlement. No counterexample indicted production. Mutants that ignore
+ the row count, let an obsolete load record coverage, or keep coverage
+ across a truncate each fail both campaigns.
+- **Storage failures** (Part 2 of the campaign): every adapter call of a
+ covered history fails once, before or after its effect, in either session.
+ Safety held at every call. A mutant that lets a failed coverage read fail
+ the load fails the sweep. Losing the acknowledgement of the coverage write
+ fails session 1 terminally, while session 2 still settles on the durable
+ rows, which is consistent with the law.
+- **Releases** (Part 3): the demand is released after each step of a load,
+ including while a coverage read is in flight. A mutant that keeps a
+ released load's wait fails the sweep.
+- **Framework witnesses** (`eff968951`): Vue, Svelte, and Angular. These
+ adapters have no network-first render gate, so the witnesses check
+ readiness and rows.
+- **Native drivers**: a better-sqlite3 witness in `node-db-sqlite-persistence`
+ and a Chromium OPFS witness in `browser-db-sqlite-persistence`, in the
+ OPFS Playwright matrix that CI runs. The OPFS witness also pins the
+ guide's statement that a multi-tab coordinator waits for the network.
+- **Mutation sweep**: reported separately below.
+
+### Mutation sweep
+
+A scripted sweep applied 63 semantic mutants to the coverage code in
+`persisted.ts`, the gate in `persisted-readiness.ts`, the builder's
+settlement signal, and the observer's source collection. Each ran against the
+fixed demand coverage oracle and the persisted-readiness oracle, and each
+survivor also ran against the receiving witnesses. Rerun against the
+generated campaign, 24 survivors left 23.
+
+The oracle then gained five histories, and each kills a survivor:
+
+- an empty covered demand after a rotation (the record ignores its cache
+ generation);
+- a truncate during the coverage read of an empty load (no check after the
+ read);
+- a synchronous answer whose durable write lands late (the coverage read
+ does not wait on the apply mutex);
+- claim recovery after an expired claim during the check (recovery not
+ scheduled);
+- a coverage read that outlasts the wait (the deadline runs from the check).
+
+The release sweep also checks that no upstream load starts after a release.
+
+Classified survivors:
+
+- Equivalent: an early `isCurrent` check, rewriting an unchanged record, the
+ on-demand check in `settleCoveredDemand`, the timer clamp, a no-op
+ unsubscribe, re-settling a settled demand, the hydrated path's refetch
+ guard (repeated in `settleCoveredDemand`), the hydrated path's
+ expired-claim guard (a read under an expired claim already proves
+ nothing), and ignoring the lifecycle generation in the reset epoch (a
+ replaced lifecycle's sync callbacks are inert).
+- Not distinguishable here: the forward guard after a release, because
+ hydration checks the abort signal at each step before it forwards a load.
+- Open, outside this owner or needing a new history: the clock read after
+ the claim wait; the hydrated mark after a truncate, which the hydration
+ path owns; and six observer and gate mutants for the persisted-readiness
+ owner. Those six skip the restore check, the preload registration and its
+ check, or the client-stream failure in `isInitialRenderReady`; take the
+ source as the demand owner; or stop rendering at once on network
+ readiness.
+
+## 2026-10-27: closing the remaining gaps
+
+Starting head: `0db1fa68b` (CI green). No production change.
+
+- **Readiness owner.** Six histories in `persisted-readiness-oracle.test.ts`
+ kill the six observer and gate survivors:
+ - a restore held past the deadline, and a gate read before the preload
+ starts;
+ - a retried observer after a failed client stream, over an empty and a
+ nonempty restore;
+ - a retried observer after the Collection's preload has started;
+ - two readers of one on-demand source, only one of which has settled;
+ - a network answer before a 60 s deadline.
+
+ The owner passes 77 of 77. One finding is left for design review: the
+ on-demand initial-render wait rarely differs from network readiness,
+ because an on-demand query becomes ready when its demands settle. Only the
+ synchronous gate distinguishes the two-reader history.
+- **Claim renewal.** A history holds the load's own cache-claim renewal past
+ the wait; a wait measured from after the renewal fails it. The first
+ attempt held the startup renewal instead, before the load started, and its
+ failure on unchanged code came from the history, not production. Every
+ survivor of the sweep in this owner is now killed or classified.
+- **Windows in the campaign.** The campaign adds the window `w` over
+ generated ranks with ties. A query is ready offline exactly when every
+ load it sent in session 2 is covered, so the model judges the
+ tie-boundary load without predicting when core sends it. A driver check
+ that every active demand loads again right after a truncate did not hold
+ for the window, and the model no longer depends on it, so it was removed.
+ Mutants that ignore window keys, compare them in order, or record keys for
+ every demand each fail the campaign. So do the five earlier campaign
+ mutants.
+- **Remaining hosts.** Receiving witnesses run the five native histories on
+ Electron (two app launches over one file, in the desktop E2E job),
+ Cloudflare Durable Objects (`wrangler dev` restarted over one persist
+ directory, in the cloud-mobile job), React Native (an op-sqlite-shaped
+ driver, cloud-mobile job), Expo (an expo-sqlite shim, not in CI), and Tauri
+ and Capacitor (their unit-suite SQLite stand-ins). In each, a 60 s
+ session-2 wait fails the two covered histories. The native Tauri and
+ Capacitor paths need `cargo` or a device here. The Expo witness failed once
+ on its first local run, under concurrent load, and passed the next ten
+ runs; the cause is unknown.
+
+## 2026-10-27: one judgment for every fixed history and receiving witness
+
+Starting head: `4855c67e9`. No production change. The maintainer asked for a
+shared receiving contract, a committed mutation harness with a nightly run,
+and more campaign grammar; reading the current `oracle-authoring` skill and
+oracle guide then showed that the fixed oracle and the witnesses broke
+ORC-016 and ORC-014 by stating their own expected answers.
+
+- **Shared judgment** (`696d319a8`, `86efc7f7f`):
+ `tests/persisted-demand-coverage-model-oracle.ts` holds the model and
+ comparison. It replays the source's own log and judges each load of a
+ query against its exact demand's record. It covers:
+ - per-load waits, generation ends, and refetch loads;
+ - failed coverage reads and checks;
+ - settlement by the upstream load, cursors, and offsets;
+ - an eager-and-on-demand join.
+
+ Parts 2 to 4 of the fixed oracle now record and pass their histories to
+ it. Their hand-written expectation tables are gone, and two pinned checks
+ owned elsewhere moved to `persisted-demand-coverage-supplemental.test.ts`.
+ Part 1 keeps its own bounded trace model.
+- **A model error found on the way:** a window's tie-boundary load starts only
+ after its limited load settles and measures its own wait. A single wait
+ premise per query predicted the window ready at 1500 ms, while production
+ correctly still waited. The premise is now per load. Production was right.
+- **Campaign grammar** (`696d319a8`): session 2 generates a 0 or 1000 ms
+ wait and a source that never answers or fails at once, with checkpoints
+ before and after each load's deadline. A mutant that settles a covered
+ load without waiting fails both campaigns.
+- **Receiving contract** (`544c64340`): `tests/contracts/` holds six
+ histories as data. Every host and framework records and passes its
+ observations to the shared judgment. All pass. With the contract's session-2
+ source forced to a 60 s wait and a silent upstream, every host and
+ framework reports violations.
+- **Mutation harness and nightly job** (`cd9544ce5`):
+ `scripts/demand-coverage-mutants.mjs` applies 66 mutants in memory through
+ `vitest.mutants.config.ts`. The full run on `cd9544ce5` matched every
+ expected outcome. The 22 mutants tied to the converted histories are each
+ killed, nearly all by the converted fixed histories themselves.
+ `.github/workflows/oracle-nightly.yml` runs the campaign with ten times its
+ PR budget and the full mutant list.
+
+Limits: the campaign generates no joins and no page moves, which the fixed
+oracle and the shared model cover; native-runtime witnesses remain blocked
+here as the coverage map records.
+
+## 2026-10-27: integrating the source-health change (#2168)
+
+Merge commit: `9262ec82d`. #2168 removed the observer's network-first gate,
+`PersistedReadinessSource`, and `packages/db/src/persisted-readiness.ts`. A
+network-first eager Collection now becomes `ready` from its restored rows,
+and Suspense releases at query readiness. The core half of this PR (the
+builder's `initialDemandsSettled()`, its listeners, and the observer's
+on-demand sources) therefore has no place left and is gone; this PR changes
+no `packages/db/src` code now. The law is unchanged. An on-demand source
+already permits initial readiness, so a joined query is ready when its eager
+source has opened and its demands on the on-demand source have settled.
+
+- **Production:** `openFromDisk` skips an on-demand Collection. Without the
+ guard, an on-demand Collection whose covered rows were restored before the
+ deadline would become `ready` at the deadline, though its source never
+ called `markReady()`. For Electric `progressive`, that status claims a
+ completed full sync.
+- **Law added to the shared model:** settling loads from SQLite never changes
+ the on-demand Collection's own status. The guide already stated it ("only
+ the load settles from SQLite"), but no oracle observed it: mutant P39, which
+ drops the guard, survived the first sweep after the merge. The model now
+ judges an optional Collection observation. The campaign, the fixed
+ lifecycle history, and the joined histories supply it. With it, P39 is
+ killed by the fixed campaign. These histories never call the source's own
+ `fail()`; that path is the source-health law's.
+- **Part 3:** the joined histories observe the query's status directly
+ instead of the removed observer gate. The source-failure histories that
+ closed H1 moved to the source-health law, which #2168 owns in
+ `packages/db/tests/source-health-oracle.test.ts`.
+- **Mutation harness:** the 29 mutants whose targets #2168 deleted (D01–D11,
+ B01–B10, O01–O08) are gone; P39 is new. 38 mutants remain.
+- **Receiving witnesses:** all 13 hosts and frameworks pass on the merge. The
+ Vue witness cleans up with `{ dependents: true }`, as #2168's cleanup
+ admission requires for a query with no component scope.
+- **Readiness oracle:** main's case that expected on-demand network-first to
+ throw contradicts this law and is removed. This owner covers that
+ configuration in every history.
+
+Limits: the Collection observation is not yet part of the receiving
+contract, so hosts and frameworks do not check it; the wrapper is the only
+production path that decides it.
+
+## 2026-10-28: high code review of the head after #2168
+
+Reviewed head: `83b10fd97` (clean worktree, files matched `gh pr diff`).
+Repairs: `de9fec4c3`; merge of #2278: `32404fa2a`. Each oracle change ran on
+the parent production code before the repair.
+
+| ID | Claim | Verdict and evidence | Action |
+| --- | --- | --- | --- |
+| R1 | An unordered demand that loses one row and gains another stays covered | True. This is the approved row-count law (maintainer decision, 2026-10-26); a fixed history pins it. The guide overclaimed that a removed row "therefore ends coverage". | Accepted design. The guide now says a row that arrived meanwhile keeps the count. |
+| R2 | A failed coverage commit fails a load whose rows applied; the synchronous path ignores it | The asymmetry is real, the consequence is not. A probe failed the coverage write before and after its effect: the Collection crashed both times, as for any durable write (glossary, "crash"), so the load's outcome decides nothing. The one non-durable rejection, an abort while scoped recovery is pending, also aborts the load's own row transactions. | Refuted as stated. The storage sweep now asserts the crash (`coverage write failed`). No mutant separates this path from the general crash law. |
+| R3 | A released load whose source resolves anyway records coverage | Confirmed. The glossary's "abort" says an aborted load's later outcome decides nothing, and the signal contract lets a source stop and resolve. RED: the campaign's new `stops and resolves` source; counterexample `start w, release w` settled `w` offline. A first run reached the state only at teardown, where production cannot record either; teardown now drops waiting loads first, and the declared reach `demand settled only by an aborted load` forced the distinguishing case. | Fixed: `recordCoverage` treats an aborted request as obsolete. Mutant P41 killed. |
+| R4 | `begin({ snapshot: true })` does not delete coverage records | Confirmed. A snapshot transaction replaces every row as a truncate does (glossary). RED: new `snapshot` command; counterexample `cover a, snapshot` settled `a` offline. | Fixed: truncate and snapshot share `endDemandCoverage`. Mutant P40 killed. |
+| R5 | `readDemandRows` lacks the scoped-recovery guard | Confirmed. Coverage counts only while the persisted subset restore returns the rows (glossary), the restore publishes none during scoped recovery, and scoped recovery does not certify the generation for offline reuse (Electric guide). RED: Part 2b `a load after scoped recovery reloaded the demand` showed `ready` on `a1`, `a2`. The first RED was false: the history started recovery before any sync run, and the driver's hook silently did nothing; the driver now throws on that premise. | Fixed: the coverage read proves nothing during scoped recovery, so no load settles from SQLite or records. The reviewer's proposed fix (reuse the restore read) would have recorded a zero-row coverage that every later restore satisfies. Mutant P42 killed. |
+| R6 | Up to three SQLite reads per load | True, and the hydrated fast path now reads when a record exists. | No change: H7 kept the read; no work promise exists. Reusing the restore's rows for the check remains an optimization. |
+| R7 | Coverage metadata grows without bound | True: one record per demand key until a truncate or rotation. | Accepted design: expiry is deferred to #2211 by maintainer decision. |
+| R8 | The model imports core's demand key | True, against AGENTS.md ("Keep Oracles Independent"). | Fixed: the model compares request data itself (offset 0 as none). Every existing history agreed. Mutant K01 (coverage keyed without cursor and offset) is killed. Mutating core's own key is not distinguishable: core's deduplication uses the same key, so the merged key changes which loads core sends, and the model reads those from the record. |
+| R9 | On-demand Collections install a no-op deadline timer; `onDemand` duplicates `syncMode` | True. `failUpstream` also calls `openFromDisk`, so its guard stays either way; skipping the timer adds a branch. | No change. |
+| R10 | The abort listener stays after the timer or upstream settles | True but bounded: core creates one signal per subset request (`subscription.ts`, `subset-demand-controller.ts`), so the listener lives only as long as its request. The "repeated loads on one signal" premise does not occur. | Refuted as a leak. |
+
+Totals: 10 items; 4 fixed (R3, R4, R5, R8), 2 accepted design (R1, R7),
+2 refuted (R2, R10), 2 no-change confirmations (R6, R9). R1 also fixed a doc
+overclaim, and R2 added a sweep assertion.
+
+Why the suite missed R3 to R5: the grammar had no snapshot transaction, no
+source that resolves an aborted load, and no online load after scoped
+recovery in the same sync run. The model shared core's demand key (R8).
+
+### Never-run queries after a completed progressive full sync
+
+The guide claims that a query that never ran renders offline once a
+progressive full sync completed. No witness checked it: the Electric witness's
+session 2 read only the subset session 1 had loaded. The shared model gains a
+`full sync completes` source event (authority: `ARCHITECTURE.md`, "A source
+that resumes from complete local rows settles its loads at once"), and the
+Electric witness's network-first block becomes a production driver for it,
+over session 1 ∈ {full sync, subset only, nothing} × session 2 reads
+{the loaded subset, a never-loaded subset}. All six histories pass; the
+full-sync, never-loaded history shows `ready` on its restored row and reaches
+`settled from a completed full sync`. Mutant E01 (drop the complete resume's
+`resolveFullSnapshot(true)` in `electric.ts`) fails exactly that history. This
+covers #2202 without a new adapter API, unlike #2203's
+`isPersistedSnapshotComplete`.
+
+
+## 2026-10-28: final high review of `d9bc29d74`
+
+Reviewed in a clean worktree whose files matched `gh pr diff`; the reviewer
+could not run tests there. No production change followed.
+
+| ID | Claim | Verdict and evidence | Action |
+| --- | --- | --- | --- |
+| S1 | A covered demand settles while a source truncate is staged but uncommitted | Refuted on the Electric path: a same-path probe (cover group a, then an open `must-refetch` in session 2, then a group-a query offline) kept the query `loading` with no rows until `up-to-date`. On the generic path the precondition needs a source transaction held open across a new demand's hydration, which the wrapper rejects at commit (`cannot cross a hydration cycle`); the oracle already excludes that history (X5). A probe history reproduced exactly that rejection and was removed. | None |
+| S2 | A rejected coverage commit fails a load whose rows applied | Duplicate of R2. The new path, a coverage transaction queued behind hydration and rejected by a later reset, belongs to a load that core already aborted at that reset (see S5), so its outcome decides nothing. | None |
+| S3 | A load without a signal, released by `unloadSubset`, records coverage | Refuted: every on-demand subset load carries its own `AbortController` signal (`subscription.ts`), and an acquisition release aborts it (glossary, "acquisition release"). | None |
+| S4 | A `BrowserCollectionCoordinator` leader without a subset owner records and settles coverage | Refuted for conforming coordinators: `registerRemoteSubsetOwner` is required, and the wrapper registers the owner synchronously when the source session starts and again inside scoped recovery's mutex turn. The OPFS witness with a real coordinator waits for the network. | None |
+| S5 | The reset epoch is captured after the source's `loadSubset` returns | Same-path history added (the source truncates synchronously inside its own load call): it passes on production, because core's truncate replay aborts the in-flight load and an aborted load records nothing. | Regression history kept |
+| S6, S7 | Extra SQLite reads and a metadata commit on the online path | Duplicates of H7 and R6. The synchronous path also reads on every deduplicated re-subscription. | No change; no work promise |
+| S8 | Unbounded coverage metadata | Duplicate of R7 (#2211). | Accepted design |
+| S9 | Abort listener and timer outlive their load | Duplicate of R10; the timer resolves an already-settled load. | None |
+| S10 | The wait starts after startup, so offline it is startup plus `networkTimeoutMs` | True, by design: the wait is a preference for the network and runs from when the wrapper asks it, as the eager deadline runs from the sync start when its source starts. | Accepted design |
+
+The full mutation sweep after the earlier repairs reported three survivors,
+because the abort check (R3) and the scoped-recovery guard (R5) now subsume
+older guards on the tested paths:
+
+- P33 (no reset-epoch check) is killed by a new history: an empty synchronous
+ answer, then a truncate during its coverage read, with the replay left
+ waiting. A synchronous answer's load has already settled, so core cannot
+ abort it, and only the epoch check stops the stale record.
+- P05 (the epoch ignores the reset sequence) and P10 (the record ignores its
+ generation) are equivalent in the single-process scope: every in-run
+ rotation or reset-sequence change that can meet a record or a load goes
+ through scoped recovery, where the coverage read proves nothing, and
+ claim recovery after startup also calls `startScopedRecovery`. A new
+ history with an empty covered demand and an expired claim confirms P10
+ survives with its premise reached.
+
+Totals: 10 items; 0 fixed, 6 refuted or duplicate-refuted (S1 to S5, S9),
+2 duplicates of no-change cost items (S6, S7), 2 accepted design (S8, S10).
diff --git a/docs/contributing/oracle-reviews/issue-2056-cache-eviction-design.md b/docs/contributing/oracle-reviews/issue-2056-cache-eviction-design.md
index 608facb12f..2c66b7aa05 100644
--- a/docs/contributing/oracle-reviews/issue-2056-cache-eviction-design.md
+++ b/docs/contributing/oracle-reviews/issue-2056-cache-eviction-design.md
@@ -323,3 +323,34 @@ demand beside an active warm tab and a peer notification delivered to an
expired receiver. The Electric descriptor owner checks restart, and the
installed-SDK owner checks subset-only network work. The remaining limits
above are recorded in the coverage map rather than inferred from green tests.
+
+## Follow-up: persisted demand coverage
+
+PR #2263 implements item 1 of #2102 in a narrower form than the issue
+proposed. The issue asked for certified predicate and window evidence that
+overlapping subsets could combine. The PR instead records persisted demand
+coverage: evidence, in one persisted cache generation, that a load of one
+exact demand reached applied settlement with its rows durably committed. Its
+law is in `2026-10-20-on-demand-network-first-law.md`, and its primary owner is
+`packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`.
+
+Coverage does not change this design's authority rules:
+
+- It is not a key-set certificate. An uncertified generation stays
+ uncertified. Coverage only lets a later load of the same demand settle from
+ rows that the same generation already holds.
+- Coverage belongs to its generation. Scoped recovery and claim-expiry
+ rotation give the next load an empty generation with no coverage, and a
+ record that memory still holds from the retired generation does not count.
+- An ordinary persisted truncate deletes every coverage record in its own
+ transaction, and a schema reset deletes collection metadata with the rows.
+- A row deleted after the covered load settled ends coverage for that demand.
+ An ordered window whose rows change asks for a tie-boundary key that no load
+ covered.
+- Overlapping demands never combine. A demand covers only its own exact key,
+ as #1797 requires for request deduplication.
+
+Shape-identity churn across new runs, described above, can alternate the
+current generation. Coverage written under one shape's generation is not
+reused under the other's.
+
diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md
index cf35e92980..a63b20c40f 100644
--- a/docs/guides/live-queries.md
+++ b/docs/guides/live-queries.md
@@ -262,7 +262,7 @@ For more details on framework integration, see the [React](../framework/react/ov
For React applications, you can use the `useLiveSuspenseQuery` hook to integrate with React Suspense boundaries. This hook suspends rendering while data loads initially, then streams updates without re-suspending.
-For eager SQLite persisted Collections, you can opt into a network-first initial render on every query source. A completed restore can release React or Solid Suspense after a network deadline or source failure. React also handles a failed client query stream this way. See [Render after SQLite restore](./sqlite-persistence.md#render-after-sqlite-restore) for setup and limits.
+For SQLite persisted Collections, you can opt into a network-first initial render on every query source. A completed restore can release React or Solid Suspense after a network deadline or source failure. On-demand sources apply this to each query demand that settled online before. React also handles a failed client query stream this way. See [Render after SQLite restore](./sqlite-persistence.md#render-after-sqlite-restore) for setup and limits.
```tsx
import { useLiveSuspenseQuery } from '@tanstack/react-db'
diff --git a/docs/guides/sqlite-persistence.md b/docs/guides/sqlite-persistence.md
index e3d4b2fae2..0ad929dbc4 100644
--- a/docs/guides/sqlite-persistence.md
+++ b/docs/guides/sqlite-persistence.md
@@ -244,7 +244,7 @@ An on-demand cache is not yet a certified offline snapshot. The first managed ge
## Render after SQLite restore
-Without `initialRender`, a persisted Collection becomes `ready` when its sync adapter calls `markReady()`, like any other Collection. The restored rows are in the Collection while it loads, but `status` stays `loading`.
+Without `initialRender`, a persisted Collection becomes `ready` when its sync adapter calls `markReady()`, like any other Collection. The restored rows are in the Collection while it loads, but `status` stays `loading`. Eager and on-demand Collections both support `initialRender`; [on-demand Collections](#on-demand-collections-offline) apply it to each query demand.
With `initialRender: { strategy: 'network-first', networkTimeoutMs }`, the Collection prefers the network for `networkTimeoutMs` (default 3 seconds). It becomes `ready` from the restored rows when both of these are true:
@@ -304,6 +304,45 @@ Place `TodosScreen` inside an [Error Boundary](./error-handling.md#using-suspens
Restored rows do not prove current authorization. Isolate SQLite data by user or tenant. If an authorization failure must hide cached rows, block the view separately: a remote failure can open the Collection from restored rows.
+### On-demand Collections offline
+
+An on-demand Collection loads rows for each query demand: the filter, order, and limit that a query asks the source for. With `initialRender`, a demand can settle on its rows from SQLite when the network does not answer. This includes Electric's `syncMode: 'progressive'`, which loads query subsets on demand until its full sync completes.
+
+```ts
+type Issue = { id: string; title: string }
+
+const issues = createCollection(
+ persistedCollectionOptions({
+ ...electricCollectionOptions({
+ id: 'issues',
+ shapeOptions: { url: 'http://localhost:3000/v1/shape', params: { table: 'issues' } },
+ syncMode: 'progressive',
+ getKey: (issue) => issue.id,
+ }),
+ persistence: syncedPersistence,
+ initialRender: { strategy: 'network-first', networkTimeoutMs: 3_000 },
+ }),
+)
+```
+
+The rule applies to each demand separately:
+
+- A demand is covered after a load of that exact demand settles online and its rows are saved in SQLite. Another filter, order, or limit is a different demand, even when its rows overlap.
+- A later load of a covered demand first shows its rows from SQLite. If the network does not answer within `networkTimeoutMs`, or it fails, the load settles on those rows. `isReady`, `preload()`, and Suspense then proceed as they do after a network load. Only the load settles from SQLite; the on-demand Collection's own status does not change.
+- The network load keeps running. Its rows replace the restored rows when they arrive. If it fails after the load settled, the query stays ready on the rows it shows.
+- A demand that never settled online is not covered. Offline, it waits for the network. A query that has never run therefore stays loading offline unless a completed progressive full sync already holds every row.
+- A source truncate, such as an Electric must-refetch, ends coverage for every demand, and so does a snapshot transaction (`begin({ snapshot: true })`), which replaces every row. A new cache generation starts without coverage.
+- Scoped recovery also ends coverage. For the rest of that sync run, no load settles from SQLite and no load records coverage.
+- A load that is released before it settles records no coverage, even if its source finishes it afterward.
+- Coverage also ends when SQLite holds fewer rows for the demand than it held when the demand settled. A row that the source deletes, or that Query DB retention removes after `gcTime`, therefore ends coverage until the demand loads online again, unless another row of the demand reached SQLite meanwhile, for example through another query. Coverage counts the rows of an unordered demand; it does not record which rows they were.
+- An ordered window with a `limit` stays covered only while SQLite holds the same rows for it, in any order. A row that leaves, joins, or replaces a row of the window ends its coverage.
+- A load with `refetch: true` always waits for the network.
+- Only the default single-process coordinator supports this. If you pass a multi-tab coordinator, such as `BrowserCollectionCoordinator`, on-demand loads do not settle from SQLite. They wait for the network, as they did before this option.
+
+Coverage proves that the rows were complete when the earlier load settled. It does not prove that they are current. The wait changes when readiness arrives, not which rows the source has. A cursor page or a demand built from other rows' values is covered only if the restored rows produce the same demand again.
+
+For a query that joins eager and on-demand sources, the query becomes ready when every eager source is ready, which network-first allows from its restored rows after the deadline, and every demand that the query reaches on an on-demand source has settled. A network failure alone does not settle an on-demand demand that is not covered.
+
## Browser tabs and Electron renderers
The browser package uses single-tab coordination by default. If tabs share one OPFS database, give the persistence instance a `BrowserCollectionCoordinator`. It elects an owner for each Collection and routes writes between tabs. Replace the initial database and persistence setup with this code in each tab:
diff --git a/packages/angular-db/tests/injectLiveQuery-persisted-on-demand.test.ts b/packages/angular-db/tests/injectLiveQuery-persisted-on-demand.test.ts
new file mode 100644
index 0000000000..597fc839d2
--- /dev/null
+++ b/packages/angular-db/tests/injectLiveQuery-persisted-on-demand.test.ts
@@ -0,0 +1,137 @@
+import { DatabaseSync } from 'node:sqlite'
+import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core'
+import { TestBed } from '@angular/core/testing'
+import { describe, expect, it, vi } from 'vitest'
+import { eq } from '@tanstack/db'
+import { SQLiteCorePersistenceAdapter } from '../../db-sqlite-persistence-core/src'
+import { injectLiveQuery } from '../src/index'
+import { createSerializedNodeSqliteDriver as createDriver } from '../../db-sqlite-persistence-core/tests/helpers/node-sqlite-driver'
+import {
+ A1,
+ A2,
+ FRAMEWORK_HISTORY_IDS,
+ coverageRecords,
+ createReceivingSource,
+ receivingHistory,
+ recordCheckpoints,
+ session2SourceOptions,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+import { coverageViolations } from '../../db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle'
+import type {
+ Session1Record,
+ Session2Record,
+ SourceEvent,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+import type { QueryObservation } from '../../db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle'
+
+/**
+ * # Does an Angular live query settle offline on a covered on-demand demand?
+ *
+ * Receiving witness for persisted demand coverage. The law, the histories, and
+ * the checkpoint records are the receiving contract's
+ * (`db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`),
+ * and the shared judge (`db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle.ts`)
+ * judges each record. The law's authority is
+ * `docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md`.
+ *
+ * Histories: `FRAMEWORK_HISTORY_IDS` (`covered`, `never`, and
+ * `covered-upstream-fails-at-once`). Session 1 is online and answers its load
+ * at once through the source, or loads nothing. Session 2 is offline on the
+ * same `node:sqlite` database, with the history's upstream behavior. Each
+ * session runs in its own environment injector, which stands in for a
+ * component's lifetime and is destroyed after the session.
+ *
+ * Production path: `injectLiveQuery` -> `createLiveQueryObserver` (wholesale
+ * mode) -> the live query's on-demand `loadSubset` -> the persisted wrapper,
+ * which waits for the network-first wait and then settles a covered demand from
+ * its restored rows.
+ *
+ * Observation: at each checkpoint of session 2, the signals `isReady` and
+ * `data`.
+ *
+ * Limits: this witness checks the wrapper's settlement through query
+ * readiness and rows. It uses one filter, one injection per session, and no template rendering.
+ */
+
+function groupA(
+ injector: EnvironmentInjector,
+ collection: ReturnType[`collection`],
+) {
+ return injector.runInContext(() =>
+ injectLiveQuery((q) =>
+ q.from({ row: collection }).where(({ row }) => eq(row.group, `a`)),
+ ),
+ )
+}
+
+function observeQuery(query: ReturnType): QueryObservation {
+ if (!query.isReady()) return { status: `loading` }
+ return {
+ status: `ready`,
+ rows: query
+ .data()
+ .map((row) => row.id)
+ .sort(),
+ }
+}
+
+describe(`Angular injectLiveQuery over a persisted on-demand Collection offline`, () => {
+ it.each(FRAMEWORK_HISTORY_IDS.map(receivingHistory))(
+ `$description`,
+ async (history) => {
+ vi.spyOn(console, `warn`).mockImplementation(() => {})
+ vi.spyOn(console, `error`).mockImplementation(() => {})
+ const parent = TestBed.inject(EnvironmentInjector)
+ const driver = createDriver(new DatabaseSync(`:memory:`))
+ const persistence = () => ({
+ adapter: new SQLiteCorePersistenceAdapter({ driver }),
+ })
+
+ const session1Events: Array = []
+ if (history.session1.length > 0) {
+ const firstInjector = createEnvironmentInjector([], parent)
+ const first = createReceivingSource({
+ persistence: persistence(),
+ waitMs: 0,
+ behavior: `answer-at-once`,
+ events: session1Events,
+ answerRows: [A1, A2],
+ })
+ const query = groupA(firstInjector, first.collection)
+ await vi.waitFor(() => expect(query.isReady()).toBe(true))
+ firstInjector.destroy()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ await first.collection.cleanup()
+ }
+ const session1: Session1Record = { events: session1Events }
+
+ const secondInjector = createEnvironmentInjector([], parent)
+ const second = createReceivingSource({
+ persistence: persistence(),
+ ...session2SourceOptions(history),
+ events: [],
+ })
+ const loadStartedAt = Date.now()
+ const query = groupA(secondInjector, second.collection)
+ try {
+ const checkpoints = await recordCheckpoints(
+ () => observeQuery(query),
+ () => second.received.length,
+ loadStartedAt,
+ )
+ const session2: Session2Record = {
+ sent: [...second.received],
+ ...checkpoints,
+ }
+ // Positive witness: session 2 asked the offline source for group a.
+ expect(session2.sent.length).toBeGreaterThan(0)
+ const records = coverageRecords(history, session1, session2)
+ expect(coverageViolations(records.beforeDeadline)).toEqual([])
+ expect(coverageViolations(records.afterDeadline)).toEqual([])
+ } finally {
+ secondInjector.destroy()
+ await second.collection.cleanup()
+ }
+ },
+ )
+})
diff --git a/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.html b/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.html
new file mode 100644
index 0000000000..80477eb2e7
--- /dev/null
+++ b/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Persisted on-demand coverage witness
+
+
+
+
+
+
diff --git a/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.spec.ts
new file mode 100644
index 0000000000..30c9a708d6
--- /dev/null
+++ b/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.spec.ts
@@ -0,0 +1,84 @@
+/**
+ * Chromium receiving spec for persisted demand coverage, over real OPFS with
+ * the wa-sqlite worker driver. The law, the histories, the production path,
+ * and the observations are the receiving contract's
+ * (`packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`),
+ * and the page `persisted-on-demand-coverage.opfs.ts` drives them. Each
+ * history is one page load for session 1 (online) and one later page load over
+ * the same OPFS file for session 2 (offline, upstream as the history says).
+ *
+ * The spec states no expected results. It checks that each run completed, that
+ * session 2 asked the offline source (the positive witness), and that the
+ * shared judge, which runs in the session 2 page, reports no violations at
+ * either checkpoint. The judge cannot load in Playwright's Node process (the
+ * `@tanstack/db` path reaches TypeScript sources that Playwright cannot
+ * transform), so the page runs it.
+ *
+ * The coordinated scenario passes `BrowserCollectionCoordinator`. The law does
+ * not cover it: the guide (`docs/guides/sqlite-persistence.md`) says only the
+ * default single-process coordinator supports this. Its run is recorded and not
+ * judged against the law.
+ */
+import { expect, test } from '@playwright/test'
+import { RECEIVING_HISTORIES } from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-histories'
+import type { Session1Record } from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+import type { Session2Result } from './persisted-on-demand-coverage.opfs'
+
+const SCENARIOS = [
+ ...RECEIVING_HISTORIES.map((history) => ({
+ id: history.id,
+ name: history.description,
+ coordinator: `none` as const,
+ })),
+ {
+ id: `covered`,
+ name: `covered demand with a multi-tab coordinator (guide: out of scope)`,
+ coordinator: `browser` as const,
+ },
+]
+
+for (const scenario of SCENARIOS) {
+ test(scenario.name, async ({ page }) => {
+ test.setTimeout(120_000)
+ const pageErrors: Array = []
+ page.on(`pageerror`, (error) => pageErrors.push(error.message))
+ const databaseName = `pod-${crypto.randomUUID().slice(0, 8)}.sqlite`
+ const url = (phase: `1` | `2`) =>
+ `/e2e/persisted-on-demand-coverage.opfs.html?phase=${phase}&history=${scenario.id}&coordinator=${scenario.coordinator}&db=${databaseName}`
+
+ await page.goto(url(`1`))
+ await page.waitForFunction(
+ () => window.__persistedOnDemandSession1 !== undefined,
+ undefined,
+ { timeout: 60_000 },
+ )
+ const session1 = await page.evaluate(
+ () => window.__persistedOnDemandSession1,
+ )
+ expect(session1).toBeDefined()
+ expect(session1).not.toHaveProperty(`error`)
+ expect((session1 as Session1Record).events.length).toBeGreaterThanOrEqual(0)
+
+ await page.goto(url(`2`))
+ await page.waitForFunction(
+ () => window.__persistedOnDemandSession2 !== undefined,
+ undefined,
+ { timeout: 60_000 },
+ )
+ const session2 = (await page.evaluate(
+ () => window.__persistedOnDemandSession2,
+ )) as Session2Result | undefined
+ expect(session2).toBeDefined()
+ expect(session2).not.toHaveProperty(`error`)
+ expect(pageErrors).toEqual([])
+ if (!session2 || !(`record` in session2)) return
+ // Positive witness: session 2 asked the offline source for the demand.
+ expect(session2.record.sent.length).toBeGreaterThan(0)
+ if (scenario.coordinator === `none`) {
+ expect(session2.violations).toEqual({
+ beforeDeadline: [],
+ afterDeadline: [],
+ })
+ }
+ })
+}
diff --git a/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.ts b/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.ts
new file mode 100644
index 0000000000..bf62433bcc
--- /dev/null
+++ b/packages/browser-db-sqlite-persistence/e2e/persisted-on-demand-coverage.opfs.ts
@@ -0,0 +1,145 @@
+/**
+ * Chromium receiving driver for persisted demand coverage, over real OPFS with
+ * the wa-sqlite worker driver. It runs one session per page load over the same
+ * OPFS database. The Playwright spec (`persisted-on-demand-coverage.opfs.spec.ts`)
+ * navigates the page from session 1 to session 2, so session 2 is a later page
+ * over the same storage.
+ *
+ * # Law, authority, and histories
+ *
+ * The law and the histories are the receiving contract's
+ * (`packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`).
+ * The law is `docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md`;
+ * the primary oracle `packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`
+ * owns it across its cells. This page is the browser host.
+ *
+ * # Production path
+ *
+ * Each session opens `openBrowserWASQLiteOPFSDatabase` and
+ * `createBrowserWASQLitePersistence`, and the contract builds the Collection
+ * with `persistedCollectionOptions`. The coordinated scenario also passes a
+ * `BrowserCollectionCoordinator`. Session 1 closes its database (and
+ * coordinator, if any) before the page navigates.
+ *
+ * # Observations
+ *
+ * The page records the session 1 source log, and the session 2 loads the
+ * source received with its two checkpoints. Session 2 then runs the shared
+ * judge (`persisted-demand-coverage-model-oracle.ts`) over those records and
+ * reports its violations. The page states no expected result itself.
+ *
+ * # Limits
+ *
+ * One driver (wa-sqlite over OPFS in Chromium, one tab at a time). It does not
+ * cover Firefox or WebKit, real multi-tab coordination, or the Electron and
+ * Node drivers (the node witness covers better-sqlite3).
+ */
+import {
+ BrowserCollectionCoordinator,
+ createBrowserWASQLitePersistence,
+ openBrowserWASQLiteOPFSDatabase,
+} from '../src/index'
+import {
+ coverageRecords,
+ receivingHistory,
+ recordSession1,
+ recordSession2,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+import { coverageViolations } from '../../db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle'
+import type {
+ ReceivingSession,
+ Session1Record,
+ Session2Record,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+
+// Session 2's record, and the shared judge's violations at each checkpoint.
+// `violations` is absent for the coordinated run, which the law does not cover.
+export type Session2Result =
+ | {
+ record: Session2Record
+ violations?: {
+ beforeDeadline: Array
+ afterDeadline: Array
+ }
+ }
+ | { error: string }
+
+declare global {
+ interface Window {
+ __persistedOnDemandSession1?: Session1Record | { error: string }
+ __persistedOnDemandSession2?: Session2Result
+ }
+}
+
+// Session 1's record reaches session 2 through sessionStorage, which the tab
+// keeps across the spec's navigation.
+const storageKey = (databaseName: string) =>
+ `persisted-demand-coverage:${databaseName}`
+
+// Opens one session's storage. `close` releases the coordinator and the
+// database so that the next page can open the same OPFS file.
+async function openSession(
+ databaseName: string,
+ coordinated: boolean,
+): Promise {
+ const database = await openBrowserWASQLiteOPFSDatabase({ databaseName })
+ // Without a coordinator the wrapper uses the default single-process
+ // coordinator, the only one the law covers (guide: sqlite-persistence.md).
+ const coordinator = coordinated
+ ? new BrowserCollectionCoordinator({ dbName: databaseName })
+ : undefined
+ const persistence = createBrowserWASQLitePersistence({
+ database,
+ ...(coordinator ? { coordinator } : {}),
+ schemaMismatchPolicy: `reset`,
+ })
+ return {
+ persistence,
+ close: async () => {
+ coordinator?.dispose()
+ await database.close?.()
+ },
+ }
+}
+
+async function main(): Promise {
+ const params = new URLSearchParams(window.location.search)
+ const phase = params.get(`phase`)
+ const history = receivingHistory(params.get(`history`)!)
+ const coordinated = params.get(`coordinator`) === `browser`
+ const databaseName = params.get(`db`)
+ if (!databaseName) throw new Error(`missing db parameter`)
+ const session = await openSession(databaseName, coordinated)
+ try {
+ if (phase === `1`) {
+ const record = await recordSession1(session.persistence, history)
+ sessionStorage.setItem(storageKey(databaseName), JSON.stringify(record))
+ window.__persistedOnDemandSession1 = record
+ return
+ }
+ const session1 = JSON.parse(
+ sessionStorage.getItem(storageKey(databaseName)) ?? `null`,
+ ) as Session1Record
+ const record = await recordSession2(session.persistence, history)
+ if (coordinated) {
+ window.__persistedOnDemandSession2 = { record }
+ return
+ }
+ const records = coverageRecords(history, session1, record)
+ window.__persistedOnDemandSession2 = {
+ record,
+ violations: {
+ beforeDeadline: coverageViolations(records.beforeDeadline),
+ afterDeadline: coverageViolations(records.afterDeadline),
+ },
+ }
+ } catch (error) {
+ if (phase === `1`)
+ window.__persistedOnDemandSession1 = { error: String(error) }
+ else window.__persistedOnDemandSession2 = { error: String(error) }
+ } finally {
+ await session.close()
+ }
+}
+
+void main()
diff --git a/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts b/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts
index f7f7eacc71..db2fc5a79d 100644
--- a/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts
+++ b/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts
@@ -16,6 +16,7 @@ export default defineConfig({
`leader-close.opfs.spec.ts`,
`opfs-request-order-oracle.opfs.spec.ts`,
`reload-class-oracle.opfs.spec.ts`,
+ `persisted-on-demand-coverage.opfs.spec.ts`,
],
timeout: 60_000,
fullyParallel: false,
diff --git a/packages/capacitor-db-sqlite-persistence/tests/capacitor-persisted-demand-coverage.test.ts b/packages/capacitor-db-sqlite-persistence/tests/capacitor-persisted-demand-coverage.test.ts
new file mode 100644
index 0000000000..d555b61fe6
--- /dev/null
+++ b/packages/capacitor-db-sqlite-persistence/tests/capacitor-persisted-demand-coverage.test.ts
@@ -0,0 +1,75 @@
+/**
+ * # Receiving witness: persisted demand coverage over the Capacitor driver
+ *
+ * Law and authority: the receiving contract
+ * (`packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`)
+ * holds the histories and records the sessions. The law is
+ * `docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md`,
+ * and the primary oracle `packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`
+ * owns it across its cells. This file is the Capacitor host. It checks the same law
+ * through the wrapper `createCapacitorSQLitePersistence`, over the better-sqlite3 stand-in in `tests/helpers/capacitor-sqlite-test-db.ts`.
+ *
+ * Histories: every history of `RECEIVING_HISTORIES`, each with its own database
+ * file. Session 1 is online and takes the history's steps. Session 2 is offline
+ * and opens a new driver on the same file, with its upstream as the history says.
+ *
+ * Production path: each session builds a `persistedCollectionOptions`
+ * Collection over `createCapacitorSQLitePersistence` and runs the query's preload. Session 1 closes
+ * its driver before session 2 opens.
+ *
+ * Observations: the records of both sessions, judged by the shared judge
+ * (`persisted-demand-coverage-model-oracle.ts`) at each session 2 checkpoint.
+ *
+ * Limits: It runs in this package's existing unit suite, which already exercises the wrapper over that stand-in. It does not run the Capacitor runtime on a device or simulator; the device e2e (`e2e/`, run with `test:e2e:ios` or `test:e2e:android`) needs an iOS simulator or an Android emulator, and `adb` is not installed here, so that path is not covered. The sessions are two driver instances in one process over one file, not an app restart.
+ */
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createCapacitorSQLitePersistence } from '../src'
+import {
+ RECEIVING_HISTORIES,
+ coverageRecords,
+ recordReceivingHistory,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+import { coverageViolations } from '../../db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle'
+import { createCapacitorSQLiteTestDatabase } from './helpers/capacitor-sqlite-test-db'
+
+const dirs: Array = []
+
+afterEach(() => {
+ for (const dir of dirs.splice(0))
+ rmSync(dir, { recursive: true, force: true })
+})
+
+const DESCRIBE = `capacitor persisted demand coverage (receiving witness)`
+
+describe(DESCRIBE, () => {
+ it.each(RECEIVING_HISTORIES)(
+ `$description`,
+ async (history) => {
+ const dir = mkdtempSync(join(tmpdir(), `capacitor-demand-coverage-`))
+ dirs.push(dir)
+ const filename = join(dir, `state.sqlite`)
+
+ const { session1, session2 } = await recordReceivingHistory(
+ history,
+ () => {
+ const database = createCapacitorSQLiteTestDatabase({ filename })
+ return Promise.resolve({
+ persistence: createCapacitorSQLitePersistence({ database }),
+ close: async () => {
+ await database.close()
+ },
+ })
+ },
+ )
+ // Positive witness: session 2 asked the offline source for the demand.
+ expect(session2.sent.length).toBeGreaterThan(0)
+ const records = coverageRecords(history, session1, session2)
+ expect(coverageViolations(records.beforeDeadline)).toEqual([])
+ expect(coverageViolations(records.afterDeadline)).toEqual([])
+ },
+ 60_000,
+ )
+})
diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-do-runtime-bridge.e2e.test.ts b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-do-runtime-bridge.e2e.test.ts
index 0f9d5b7118..abd5300505 100644
--- a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-do-runtime-bridge.e2e.test.ts
+++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-do-runtime-bridge.e2e.test.ts
@@ -1,244 +1,16 @@
import { mkdtempSync, rmSync } from 'node:fs'
-import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { setTimeout as delay } from 'node:timers/promises'
-import { fileURLToPath } from 'node:url'
-import { spawn } from 'node:child_process'
+import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { runRuntimeBridgeE2EContractSuite } from '../../db-sqlite-persistence-core/tests/contracts/runtime-bridge-e2e-contract'
+import { postJson, startWranglerRuntime } from './fixtures/wrangler-runtime'
import type {
RuntimeBridgeE2EContractError,
RuntimeBridgeE2EContractHarness,
RuntimeBridgeE2EContractHarnessFactory,
RuntimeBridgeE2EContractTodo,
} from '../../db-sqlite-persistence-core/tests/contracts/runtime-bridge-e2e-contract'
-
-type RuntimeProcessHarness = {
- baseUrl: string
- restart: () => Promise
- stop: () => Promise
- getOutput: () => {
- stderr: string
- stdout: string
- }
-}
-
-type WranglerRuntimeResponse =
- | {
- ok: true
- rows?: TPayload
- }
- | {
- ok: false
- error: RuntimeBridgeE2EContractError
- }
-
-const packageDirectory = dirname(fileURLToPath(import.meta.url))
-const wranglerConfigPath = join(packageDirectory, `fixtures`, `wrangler.toml`)
-const require = createRequire(import.meta.url)
-const wranglerBinPath = require.resolve(`wrangler/bin/wrangler.js`)
-
-async function getAvailablePort(): Promise {
- const netModule = await import('node:net')
- return new Promise((resolve, reject) => {
- const server = netModule.createServer()
- server.listen(0, `127.0.0.1`, () => {
- const address = server.address()
- if (!address || typeof address === `string`) {
- server.close()
- reject(new Error(`Unable to allocate an available local port`))
- return
- }
- const selectedPort = address.port
- server.close((error) => {
- if (error) {
- reject(error)
- return
- }
- resolve(selectedPort)
- })
- })
- server.on(`error`, reject)
- })
-}
-
-function createRuntimeError(
- message: string,
- stderr: string,
- stdout: string,
-): Error {
- return new Error([message, `stderr=${stderr}`, `stdout=${stdout}`].join(`\n`))
-}
-
-async function stopWranglerProcess(
- child: ReturnType | undefined,
-): Promise {
- if (!child || child.exitCode !== null) {
- return
- }
-
- child.kill(`SIGTERM`)
- const closed = await Promise.race([
- new Promise((resolve) => {
- child.once(`close`, () => resolve(true))
- }),
- delay(5_000).then(() => false),
- ])
-
- if (closed) {
- return
- }
-
- child.kill(`SIGKILL`)
- await new Promise((resolve) => {
- child.once(`close`, () => resolve())
- })
-}
-
-async function startWranglerRuntime(options: {
- persistPath: string
- syncEnabled?: boolean
- schemaVersion?: number
- collectionId?: string
-}): Promise {
- let child: ReturnType | undefined
- let stdoutBuffer = ``
- let stderrBuffer = ``
- const port = await getAvailablePort()
-
- const spawnProcess = async (): Promise => {
- const runtimeVarEntries = [
- [
- `PERSISTENCE_WITH_SYNC`,
- options.syncEnabled !== undefined
- ? String(options.syncEnabled)
- : undefined,
- ],
- [
- `PERSISTENCE_SCHEMA_VERSION`,
- options.schemaVersion !== undefined
- ? String(options.schemaVersion)
- : undefined,
- ],
- [`PERSISTENCE_COLLECTION_ID`, options.collectionId],
- ].filter((entry): entry is [string, string] => entry[1] !== undefined)
-
- const wranglerArgs = [
- `dev`,
- `--local`,
- `--ip`,
- `127.0.0.1`,
- `--port`,
- String(port),
- `--persist-to`,
- options.persistPath,
- `--config`,
- wranglerConfigPath,
- ...runtimeVarEntries.flatMap(([key, value]) => [
- `--var`,
- `${key}:${value}`,
- ]),
- ]
-
- child = spawn(process.execPath, [wranglerBinPath, ...wranglerArgs], {
- cwd: packageDirectory,
- env: {
- ...process.env,
- CI: `1`,
- WRANGLER_SEND_METRICS: `false`,
- },
- stdio: [`ignore`, `pipe`, `pipe`],
- })
-
- if (!child.stdout || !child.stderr) {
- throw new Error(`Unable to capture wrangler dev process output streams`)
- }
-
- child.stdout.on(`data`, (chunk: Buffer) => {
- stdoutBuffer += chunk.toString()
- })
- child.stderr.on(`data`, (chunk: Buffer) => {
- stderrBuffer += chunk.toString()
- })
-
- const baseUrl = `http://127.0.0.1:${String(port)}`
- const startAt = Date.now()
- while (Date.now() - startAt < 45_000) {
- if (child.exitCode !== null) {
- throw createRuntimeError(
- `Wrangler dev exited before becoming healthy`,
- stderrBuffer,
- stdoutBuffer,
- )
- }
-
- try {
- const healthResponse = await fetch(`${baseUrl}/health`)
- if (healthResponse.ok) {
- return
- }
- } catch {
- // Runtime may still be starting.
- }
-
- await delay(250)
- }
-
- throw createRuntimeError(
- `Timed out waiting for wrangler dev runtime`,
- stderrBuffer,
- stdoutBuffer,
- )
- }
-
- await spawnProcess()
-
- return {
- baseUrl: `http://127.0.0.1:${String(port)}`,
- restart: async () => {
- await stopWranglerProcess(child)
- stdoutBuffer = ``
- stderrBuffer = ``
- await spawnProcess()
- },
- stop: async () => {
- await stopWranglerProcess(child)
- },
- getOutput: () => ({
- stderr: stderrBuffer,
- stdout: stdoutBuffer,
- }),
- }
-}
-
-async function postJson(
- runtime: RuntimeProcessHarness,
- path: string,
- body: unknown,
-): Promise> {
- let response: Response
- try {
- response = await fetch(`${runtime.baseUrl}${path}`, {
- method: `POST`,
- headers: {
- 'content-type': `application/json`,
- },
- body: JSON.stringify(body),
- signal: AbortSignal.timeout(30_000),
- })
- } catch (error) {
- const output = runtime.getOutput()
- throw createRuntimeError(
- `Timed out waiting for wrangler dev runtime response: ${String(error)}`,
- output.stderr,
- output.stdout,
- )
- }
-
- const parsed = (await response.json()) as WranglerRuntimeResponse
- return parsed
-}
+import type { WranglerRuntimeResponse } from './fixtures/wrangler-runtime'
function assertRuntimeError(
response: WranglerRuntimeResponse,
diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-persisted-demand-coverage.e2e.test.ts b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-persisted-demand-coverage.e2e.test.ts
new file mode 100644
index 0000000000..6f394214d9
--- /dev/null
+++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/cloudflare-persisted-demand-coverage.e2e.test.ts
@@ -0,0 +1,101 @@
+/**
+ * # Receiving witness: persisted demand coverage over Cloudflare Durable Objects
+ *
+ * Law and authority: the receiving contract
+ * (`packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`)
+ * holds the histories and records the sessions. The law is
+ * `docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md`,
+ * and the primary oracle `packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`
+ * owns it across its cells. This file is the Cloudflare host: it checks the
+ * same law through the Durable Object persistence
+ * (`createCloudflareDOSQLitePersistence`) inside a real workerd runtime started
+ * by `wrangler dev --local --persist-to`. The primary oracle does not run workerd.
+ *
+ * Histories: every history of `RECEIVING_HISTORIES`. Each history has its own
+ * wrangler persist directory.
+ *
+ * Production path: the test starts wrangler and posts session 1 to the Durable
+ * Object (`/demand-session`, phase `session1`). The Durable Object runs the
+ * contract's recorder in `e2e/fixtures/demand-coverage-session.mjs` over its
+ * own storage. The test stops wrangler, starts it again over the same persist
+ * directory, and posts session 2 (phase `session2`).
+ *
+ * Observations: the session 1 source log, and the session 2 loads and two
+ * checkpoints, read inside the Durable Object.
+ *
+ * Limits: local workerd with the Durable Object's SQLite storage, one Durable
+ * Object name, and a process restart between the sessions. It does not cover
+ * deployed Cloudflare, the browser or React adapter, or multi-object
+ * coordination.
+ */
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+ RECEIVING_HISTORIES,
+ coverageRecords,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+import { coverageViolations } from '../../db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle'
+import { postJson, startWranglerRuntime } from './fixtures/wrangler-runtime'
+import type {
+ Session1Record,
+ Session2Record,
+} from '../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract'
+
+describe(`cloudflare durable object persisted demand coverage (wrangler local, receiving witness)`, () => {
+ it.each(RECEIVING_HISTORIES)(
+ `$description`,
+ async (history) => {
+ const tempDirectory = mkdtempSync(join(tmpdir(), `db-cloudflare-demand-`))
+ const persistPath = join(tempDirectory, `wrangler-state`)
+ try {
+ // Session 1 is online. It runs in one wrangler process, then stops.
+ const first = await startWranglerRuntime({ persistPath })
+ let session1Record: Session1Record | undefined
+ try {
+ const session1 = await postJson<{ record: Session1Record }>(
+ first,
+ `/demand-session`,
+ { phase: `session1`, history: history.id },
+ )
+ if (!session1.ok) {
+ throw new Error(`${session1.error.name}: ${session1.error.message}`)
+ }
+ session1Record = session1.rows?.record
+ } finally {
+ await first.stop()
+ }
+
+ // Session 2 is a later process over the same persist directory.
+ const second = await startWranglerRuntime({ persistPath })
+ try {
+ const session2 = await postJson<{ record: Session2Record }>(
+ second,
+ `/demand-session`,
+ { phase: `session2`, history: history.id },
+ )
+ if (!session2.ok) {
+ throw new Error(`${session2.error.name}: ${session2.error.message}`)
+ }
+ const session2Record = session2.rows?.record
+ // Positive witness: session 2 asked the offline source.
+ expect(session2Record?.sent.length ?? 0).toBeGreaterThan(0)
+ expect(session1Record).toBeDefined()
+ const records = coverageRecords(
+ history,
+ session1Record as Session1Record,
+ session2Record as Session2Record,
+ )
+ expect(coverageViolations(records.beforeDeadline)).toEqual([])
+ expect(coverageViolations(records.afterDeadline)).toEqual([])
+ } finally {
+ await second.stop()
+ }
+ } finally {
+ rmSync(tempDirectory, { recursive: true, force: true })
+ }
+ },
+ 90_000,
+ )
+})
diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/demand-coverage-session.mjs b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/demand-coverage-session.mjs
new file mode 100644
index 0000000000..de74f20aa3
--- /dev/null
+++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/demand-coverage-session.mjs
@@ -0,0 +1,35 @@
+// @ts-nocheck
+/**
+ * Durable Object side of the persisted demand coverage witness
+ * (`e2e/cloudflare-persisted-demand-coverage.e2e.test.ts`). The worker runs
+ * one session per request, against the Durable Object's own storage, through
+ * the real `createCloudflareDOSQLitePersistence`. The test restarts wrangler
+ * between session 1 and session 2, so session 2 reads what workerd persisted.
+ * The histories and the session runners are the receiving contract's
+ * (`packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts`),
+ * imported here into the worker bundle. This module records what it observes;
+ * the test owns the judgment, through the shared judge once it is published.
+ */
+import {
+ receivingHistory,
+ recordSession1,
+ recordSession2,
+} from '../../../db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts'
+import { createCloudflareDOSQLitePersistence } from '../../dist/esm/index.js'
+
+// Runs one session. `phase` is `session1` (online, takes the history's steps)
+// or `session2` (offline, returns the record). Errors reject to the caller.
+export async function runDemandCoverageSession(storage, body) {
+ const persistence = createCloudflareDOSQLitePersistence({ storage })
+ const history = receivingHistory(body.history)
+ if (body.phase === `session1`) {
+ return {
+ phase: `session1`,
+ record: await recordSession1(persistence, history),
+ }
+ }
+ return {
+ phase: `session2`,
+ record: await recordSession2(persistence, history),
+ }
+}
diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs
index b401358b43..7cd1e550ba 100644
--- a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs
+++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/worker.mjs
@@ -9,6 +9,7 @@ import {
createCloudflareDOSQLitePersistence,
persistedCollectionOptions,
} from '../../dist/esm/index.js'
+import { runDemandCoverageSession } from './demand-coverage-session.mjs'
const DEFAULT_COLLECTION_ID = `todos`
const DEFAULT_SCHEMA_VERSION = 1
@@ -170,6 +171,13 @@ export class PersistenceObject extends DurableObject {
const requestBody = await request.json()
const collectionId = requestBody.collectionId ?? this.collectionId
+ if (request.method === `POST` && url.pathname === `/demand-session`) {
+ return jsonResponse(200, {
+ ok: true,
+ rows: await runDemandCoverageSession(this.ctx.storage, requestBody),
+ })
+ }
+
if (request.method === `POST` && url.pathname === `/write-todo`) {
if (collectionId !== this.collectionId) {
throw createUnknownCollectionError(collectionId)
diff --git a/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/wrangler-runtime.ts b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/wrangler-runtime.ts
new file mode 100644
index 0000000000..b439440d59
--- /dev/null
+++ b/packages/cloudflare-durable-objects-db-sqlite-persistence/e2e/fixtures/wrangler-runtime.ts
@@ -0,0 +1,238 @@
+import { spawn } from 'node:child_process'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { setTimeout as delay } from 'node:timers/promises'
+import { fileURLToPath } from 'node:url'
+import type { RuntimeBridgeE2EContractError } from '../../../db-sqlite-persistence-core/tests/contracts/runtime-bridge-e2e-contract'
+
+/**
+ * Wrangler dev process helpers shared by the Cloudflare Durable Object e2e
+ * witnesses. `startWranglerRuntime` runs the fixture worker in local workerd
+ * with `--persist-to`, so a runtime that is stopped and started again over
+ * the same directory reads the same Durable Object storage.
+ */
+export type RuntimeProcessHarness = {
+ baseUrl: string
+ restart: () => Promise
+ stop: () => Promise
+ getOutput: () => {
+ stderr: string
+ stdout: string
+ }
+}
+
+export type WranglerRuntimeResponse =
+ | {
+ ok: true
+ rows?: TPayload
+ }
+ | {
+ ok: false
+ error: RuntimeBridgeE2EContractError
+ }
+
+const packageDirectory = dirname(fileURLToPath(import.meta.url))
+const wranglerConfigPath = join(packageDirectory, `wrangler.toml`)
+const require = createRequire(import.meta.url)
+const wranglerBinPath = require.resolve(`wrangler/bin/wrangler.js`)
+
+async function getAvailablePort(): Promise {
+ const netModule = await import('node:net')
+ return new Promise((resolve, reject) => {
+ const server = netModule.createServer()
+ server.listen(0, `127.0.0.1`, () => {
+ const address = server.address()
+ if (!address || typeof address === `string`) {
+ server.close()
+ reject(new Error(`Unable to allocate an available local port`))
+ return
+ }
+ const selectedPort = address.port
+ server.close((error) => {
+ if (error) {
+ reject(error)
+ return
+ }
+ resolve(selectedPort)
+ })
+ })
+ server.on(`error`, reject)
+ })
+}
+
+function createRuntimeError(
+ message: string,
+ stderr: string,
+ stdout: string,
+): Error {
+ return new Error([message, `stderr=${stderr}`, `stdout=${stdout}`].join(`\n`))
+}
+
+async function stopWranglerProcess(
+ child: ReturnType | undefined,
+): Promise {
+ if (!child || child.exitCode !== null) {
+ return
+ }
+
+ child.kill(`SIGTERM`)
+ const closed = await Promise.race([
+ new Promise((resolve) => {
+ child.once(`close`, () => resolve(true))
+ }),
+ delay(5_000).then(() => false),
+ ])
+
+ if (closed) {
+ return
+ }
+
+ child.kill(`SIGKILL`)
+ await new Promise((resolve) => {
+ child.once(`close`, () => resolve())
+ })
+}
+
+export async function startWranglerRuntime(options: {
+ persistPath: string
+ syncEnabled?: boolean
+ schemaVersion?: number
+ collectionId?: string
+}): Promise {
+ let child: ReturnType | undefined
+ let stdoutBuffer = ``
+ let stderrBuffer = ``
+ const port = await getAvailablePort()
+
+ const spawnProcess = async (): Promise => {
+ const runtimeVarEntries = [
+ [
+ `PERSISTENCE_WITH_SYNC`,
+ options.syncEnabled !== undefined
+ ? String(options.syncEnabled)
+ : undefined,
+ ],
+ [
+ `PERSISTENCE_SCHEMA_VERSION`,
+ options.schemaVersion !== undefined
+ ? String(options.schemaVersion)
+ : undefined,
+ ],
+ [`PERSISTENCE_COLLECTION_ID`, options.collectionId],
+ ].filter((entry): entry is [string, string] => entry[1] !== undefined)
+
+ const wranglerArgs = [
+ `dev`,
+ `--local`,
+ `--ip`,
+ `127.0.0.1`,
+ `--port`,
+ String(port),
+ `--persist-to`,
+ options.persistPath,
+ `--config`,
+ wranglerConfigPath,
+ ...runtimeVarEntries.flatMap(([key, value]) => [
+ `--var`,
+ `${key}:${value}`,
+ ]),
+ ]
+
+ child = spawn(process.execPath, [wranglerBinPath, ...wranglerArgs], {
+ cwd: packageDirectory,
+ env: {
+ ...process.env,
+ CI: `1`,
+ WRANGLER_SEND_METRICS: `false`,
+ },
+ stdio: [`ignore`, `pipe`, `pipe`],
+ })
+
+ if (!child.stdout || !child.stderr) {
+ throw new Error(`Unable to capture wrangler dev process output streams`)
+ }
+
+ child.stdout.on(`data`, (chunk: Buffer) => {
+ stdoutBuffer += chunk.toString()
+ })
+ child.stderr.on(`data`, (chunk: Buffer) => {
+ stderrBuffer += chunk.toString()
+ })
+
+ const baseUrl = `http://127.0.0.1:${String(port)}`
+ const startAt = Date.now()
+ while (Date.now() - startAt < 45_000) {
+ if (child.exitCode !== null) {
+ throw createRuntimeError(
+ `Wrangler dev exited before becoming healthy`,
+ stderrBuffer,
+ stdoutBuffer,
+ )
+ }
+
+ try {
+ const healthResponse = await fetch(`${baseUrl}/health`)
+ if (healthResponse.ok) {
+ return
+ }
+ } catch {
+ // Runtime may still be starting.
+ }
+
+ await delay(250)
+ }
+
+ throw createRuntimeError(
+ `Timed out waiting for wrangler dev runtime`,
+ stderrBuffer,
+ stdoutBuffer,
+ )
+ }
+
+ await spawnProcess()
+
+ return {
+ baseUrl: `http://127.0.0.1:${String(port)}`,
+ restart: async () => {
+ await stopWranglerProcess(child)
+ stdoutBuffer = ``
+ stderrBuffer = ``
+ await spawnProcess()
+ },
+ stop: async () => {
+ await stopWranglerProcess(child)
+ },
+ getOutput: () => ({
+ stderr: stderrBuffer,
+ stdout: stdoutBuffer,
+ }),
+ }
+}
+
+export async function postJson(
+ runtime: RuntimeProcessHarness,
+ path: string,
+ body: unknown,
+): Promise> {
+ let response: Response
+ try {
+ response = await fetch(`${runtime.baseUrl}${path}`, {
+ method: `POST`,
+ headers: {
+ 'content-type': `application/json`,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(30_000),
+ })
+ } catch (error) {
+ const output = runtime.getOutput()
+ throw createRuntimeError(
+ `Timed out waiting for wrangler dev runtime response: ${String(error)}`,
+ output.stderr,
+ output.stdout,
+ )
+ }
+
+ const parsed = (await response.json()) as WranglerRuntimeResponse
+ return parsed
+}
diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts
index 239a64af79..d5f170b0f5 100644
--- a/packages/db-sqlite-persistence-core/src/persisted.ts
+++ b/packages/db-sqlite-persistence-core/src/persisted.ts
@@ -812,7 +812,10 @@ class PersistedReadinessTracker {
private snapshot: PersistedReadinessSnapshot = { status: `loading` }
private readonly listeners = new Set<() => void>()
- constructor(readonly networkTimeoutMs: number) {}
+ constructor(
+ readonly networkTimeoutMs: number,
+ readonly onDemand: boolean,
+ ) {}
getSnapshot = (): PersistedReadinessSnapshot => this.snapshot
@@ -840,6 +843,20 @@ export type PersistedInitialRenderOptions = {
}
const DEFAULT_NETWORK_TIMEOUT_MS = 3_000
+// Persisted demand coverage: a load of this exact demand key reached applied
+// settlement with its rows durably committed in this cache generation.
+const DEMAND_COVERAGE_PREFIX = `persisted:coverage:`
+const demandCoverageKey = (options: LoadSubsetOptions): string =>
+ `${DEMAND_COVERAGE_PREFIX}${getLoadSubsetDemandKey(options)}`
+// A window covers exactly the rows it held (maintainer decision,
+// 2026-10-26), so its coverage also records their keys, in any order.
+const windowKeys = (
+ options: LoadSubsetOptions,
+ rows: ReadonlyArray<{ key: string | number }>,
+): string | undefined =>
+ options.limit === undefined
+ ? undefined
+ : JSON.stringify(rows.map(({ key }) => key).sort())
const CACHE_CLAIM_RENEW_WINDOW_MS = 60_000
const MAX_TIMER_TIMEOUT_MS = 2_147_483_647
@@ -2218,6 +2235,17 @@ class PersistedCollectionRuntime<
: undefined
}
+ // A reset or a new lifecycle makes an in-flight load obsolete (law 6 in
+ // `ARCHITECTURE.md`), so its settlement cannot record coverage.
+ captureResetEpoch(): () => boolean {
+ const { lifecycleGeneration, resetSequence, sourceTruncateGeneration } =
+ this
+ return () =>
+ lifecycleGeneration === this.lifecycleGeneration &&
+ resetSequence === this.resetSequence &&
+ sourceTruncateGeneration === this.sourceTruncateGeneration
+ }
+
getLifecycleGeneration(): number {
return this.lifecycleGeneration
}
@@ -2947,6 +2975,7 @@ class PersistedCollectionRuntime<
onHydrationStarted?: () => void,
isDemandRetained?: () => boolean,
): Promise {
+ const startedAt = Date.now()
this.throwIfTerminal()
await this.ensureCacheClaimActive()
// The caller can retire demand while claim renewal or rotation is awaited.
@@ -3099,12 +3128,132 @@ class PersistedCollectionRuntime<
}
if (upstreamLoadSubset) {
- try {
- await upstreamLoadSubset(options)
- } catch (error) {
- this.noteUpstreamLoadFailure(options, error)
- throw error
+ const upstream = (async () => {
+ try {
+ await upstreamLoadSubset(options)
+ } catch (error) {
+ this.noteUpstreamLoadFailure(options, error)
+ throw error
+ }
+ })()
+ await this.settleCoveredDemand(options, upstream, startedAt)
+ }
+ }
+
+ // The persisted subset restore of this demand is visible. A covered demand
+ // of an opted-in on-demand Collection settles once the wait since the load
+ // started has passed or its upstream load has failed. The upstream load
+ // keeps running; its rows are ordinary source writes, and a later failure
+ // is reported but leaves the demand settled.
+ //
+ // A refetch starts a new acquisition that waits for its own rows
+ // (`ARCHITECTURE.md`, "Demand grouping and ownership"), so coverage never
+ // settles it.
+ private async settleCoveredDemand(
+ options: LoadSubsetOptions,
+ upstream: Promise,
+ startedAt: number,
+ ): Promise {
+ // The coverage read below can outlast a fast upstream failure.
+ void upstream.catch(() => undefined)
+ if (
+ !this.persistedReadiness?.onDemand ||
+ options.refetch === true ||
+ !(await this.isDemandCovered(options))
+ ) {
+ return upstream
+ }
+ // A released load owns no wait, even if its source ignores the abort.
+ if (options.signal?.aborted) return
+ const deadlineAt = startedAt + this.persistedReadiness.networkTimeoutMs
+ return new Promise((resolve) => {
+ const timer = setTimeout(resolve, Math.max(0, deadlineAt - Date.now()))
+ const settle = () => {
+ clearTimeout(timer)
+ resolve()
+ }
+ options.signal?.addEventListener(`abort`, settle, { once: true })
+ void upstream.catch(() => undefined).then(settle)
+ })
+ }
+
+ // Coverage belongs to one persisted cache generation. A rotation changes
+ // the storage ID, so a record that memory still holds from the previous
+ // generation does not count.
+ private currentCoverageRecord(
+ options: LoadSubsetOptions,
+ ): { rows: number; keys?: string } | undefined {
+ const record = this.syncControls.metadata?.collection.get(
+ demandCoverageKey(options),
+ ) as { generation?: unknown; rows?: unknown; keys?: string } | undefined
+ return record?.generation === this.storageCollectionId &&
+ typeof record.rows === `number`
+ ? { rows: record.rows, keys: record.keys }
+ : undefined
+ }
+
+ // The rows that this demand reads from SQLite in the current generation.
+ // The read waits behind every source transaction already committed, so it
+ // sees their rows durably. Coverage is bookkeeping: a failed read proves
+ // nothing and returns undefined, and the load keeps its own outcome. A
+ // read under an expired cache claim also starts claim recovery. Scoped
+ // recovery leaves the rest of its sync run uncertified, and its persisted
+ // subset restore publishes no rows, so no read proves coverage then.
+ private async readDemandRows(
+ options: LoadSubsetOptions,
+ ): Promise | undefined> {
+ if (this.scopedRecovery) return undefined
+ try {
+ return await this.applyMutex.run(() =>
+ this.persistence.adapter.loadSubset(this.storageTargetFor(), options, {
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
+ cacheGenerationClaimId: this.cacheGenerationClaim?.claimId,
+ }),
+ )
+ } catch {
+ if (this.hasExpiredCacheClaim()) {
+ this.scheduleCacheClaimRecovery(this.lifecycleGeneration)
}
+ return undefined
+ }
+ }
+
+ // A row deleted after the covered load settled, by the source or by
+ // retention, leaves fewer rows than coverage recorded, so it no longer
+ // proves the demand complete (maintainer decision, 2026-10-26). A window
+ // must also hold the same rows.
+ private async isDemandCovered(options: LoadSubsetOptions): Promise {
+ const record = this.currentCoverageRecord(options)
+ if (record === undefined) return false
+ const rows = await this.readDemandRows(options)
+ return (
+ rows !== undefined &&
+ rows.length >= record.rows &&
+ windowKeys(options, rows) === record.keys
+ )
+ }
+
+ // The coverage record for a load that just settled, or undefined when the
+ // current record already says the same.
+ async nextCoverageRecord(options: LoadSubsetOptions): Promise<
+ | {
+ settledAt: number
+ generation: string
+ rows: number
+ keys?: string
+ }
+ | undefined
+ > {
+ const rows = await this.readDemandRows(options)
+ if (rows === undefined) return undefined
+ const keys = windowKeys(options, rows)
+ const current = this.currentCoverageRecord(options)
+ if (current?.rows === rows.length && current.keys === keys) return undefined
+ return {
+ settledAt: Date.now(),
+ generation: this.storageCollectionId,
+ rows: rows.length,
+ keys,
}
}
@@ -3137,14 +3286,18 @@ class PersistedCollectionRuntime<
return true
}
- return result.then(
- () => {
- this.queueRemoteSubsetEnsure(options)
- },
- (error: unknown) => {
- this.noteUpstreamLoadFailure(options, error)
- throw error
- },
+ return this.settleCoveredDemand(
+ options,
+ result.then(
+ () => {
+ this.queueRemoteSubsetEnsure(options)
+ },
+ (error: unknown) => {
+ this.noteUpstreamLoadFailure(options, error)
+ throw error
+ },
+ ),
+ Date.now(),
)
}
@@ -6343,9 +6496,18 @@ function createWrappedSyncConfig<
// Network-first initial rendering: once the deadline passes, or the
// remote fails, a completed restore that installed rows opens the
// Collection. An empty restore keeps it loading, so an empty list never
- // stands for data the network has not answered yet.
+ // stands for data the network has not answered yet. An on-demand
+ // Collection restores per demand instead: each covered load settles
+ // from SQLite after its own wait (`settleCoveredDemand`), and query
+ // readiness follows its loads.
const openFromDisk = () => {
- if (!persistedReadiness || isCleanedUp() || getTerminalFailure()) return
+ if (
+ !persistedReadiness ||
+ persistedReadiness.onDemand ||
+ isCleanedUp() ||
+ getTerminalFailure()
+ )
+ return
if (persistedReadiness.getSnapshot().status !== `ready`) return
if (params.collection.status !== `loading`) return
if (params.collection.size === 0) return
@@ -6598,6 +6760,15 @@ function createWrappedSyncConfig<
}
const reportUpstreamError = (error: unknown) =>
afterStartup(() => failUpstream(error))
+ // Persisted demand coverage ends with the rows it described, at a
+ // truncate and at a snapshot transaction, which replaces every row.
+ const endDemandCoverage = (transaction: OpenSyncTransaction) => {
+ for (const { key } of wrappedParams.metadata?.collection.list(
+ DEMAND_COVERAGE_PREFIX,
+ ) ?? []) {
+ transaction.collectionMetadataWrites.set(key, { type: `delete` })
+ }
+ }
const wrappedParams = {
...params,
fail: reportUpstreamError,
@@ -6643,6 +6814,7 @@ function createWrappedSyncConfig<
queuedBecauseHydrating: false,
...(terminalFailure === undefined ? {} : { terminalFailure }),
}
+ if (options?.snapshot) endDemandCoverage(transaction)
transactionStack.push(transaction)
},
write: (message: ChangeMessageOrDeleteKeyMessage) => {
@@ -6861,6 +7033,7 @@ function createWrappedSyncConfig<
// Callers (for example electric resume/reset handling) may stage
// collection-scoped metadata before truncating row data, and those
// writes must commit atomically with the truncate transaction.
+ endDemandCoverage(openTransaction)
openTransaction.truncate = true
openTransaction.truncateOptions = options ?? {}
},
@@ -7096,6 +7269,30 @@ function createWrappedSyncConfig<
}
}
+ // The load's rows committed first; this metadata-only source
+ // transaction persists after them, so coverage never outlives a failed
+ // row write.
+ // Only a current load records coverage. A reset makes the record
+ // obsolete before its commit could be aborted, so a current record
+ // fails only with a durable failure that already fails the runtime.
+ // A load that core aborted decides nothing, even if its source
+ // resolves it.
+ const recordCoverage = async (
+ demand: LoadSubsetOptions,
+ isCurrent: () => boolean,
+ ): Promise => {
+ const current = () => isCurrent() && !demand.signal?.aborted
+ if (!current()) return
+ const record = await runtime.nextCoverageRecord(demand)
+ if (!record || !current()) return
+ wrappedParams.begin()
+ wrappedParams.metadata?.collection.set(
+ demandCoverageKey(demand),
+ record,
+ )
+ await wrappedParams.commit()
+ }
+
let sourceResult: SyncConfigRes = {}
let sourceResultSettled = false
let activeSourceSyncEntry: Promise | undefined
@@ -7302,7 +7499,16 @@ function createWrappedSyncConfig<
try {
// Returning a promise transfers its lease even if it rejects.
// Only a synchronous throw leaves no upstream lease to release.
- return currentSource.loadSubset(loadOptions)
+ const loaded = currentSource.loadSubset(loadOptions)
+ if (!persistedReadiness?.onDemand) return loaded
+ const isCurrent = runtime.captureResetEpoch()
+ // A synchronous answer stays synchronous. Its coverage record
+ // follows its rows in commit order without holding the load.
+ if (loaded === true) {
+ void recordCoverage(options, isCurrent).catch(() => undefined)
+ return true
+ }
+ return loaded.then(() => recordCoverage(options, isCurrent))
} catch (error) {
acquisition.forwarded = false
acquisition.source = undefined
@@ -7546,14 +7752,12 @@ export function persistedCollectionOptions<
}
const networkTimeoutMs = getNetworkTimeoutMs(options.initialRender)
- if (networkTimeoutMs !== undefined && options.syncMode === `on-demand`) {
- throw new InvalidPersistedCollectionConfigError(
- `network-first initial rendering requires eager persistence syncMode`,
- )
- }
const persistedReadinessTracker =
networkTimeoutMs !== undefined
- ? new PersistedReadinessTracker(networkTimeoutMs)
+ ? new PersistedReadinessTracker(
+ networkTimeoutMs,
+ options.syncMode === `on-demand`,
+ )
: undefined
let claimed = false
diff --git a/packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts
new file mode 100644
index 0000000000..2a54548693
--- /dev/null
+++ b/packages/db-sqlite-persistence-core/tests/contracts/persisted-demand-coverage-receiving-contract.ts
@@ -0,0 +1,507 @@
+/**
+ * # Receiving contract: persisted demand coverage
+ *
+ * Law (`docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md`,
+ * glossary entry "persisted demand coverage"): with
+ * `initialRender: { strategy: 'network-first', networkTimeoutMs }` on a
+ * persisted `syncMode: 'on-demand'` Collection, a demand settles from storage
+ * in a later offline session when all three hold:
+ *
+ * 1. the persisted subset restore has published its rows in the current sync
+ * run;
+ * 2. the exact demand has persisted demand coverage, recorded by an earlier
+ * online load of that demand in the same cache generation;
+ * 3. the configured wait has passed since the load started, or the demand's
+ * upstream load has failed.
+ *
+ * Authority: the primary oracle `packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`
+ * and the campaign oracle `persisted-demand-coverage-campaign-oracle.test.ts`
+ * own the law. The judge that compares a record with the law is shared, and
+ * this module holds no expected result.
+ *
+ * ## What this module holds
+ *
+ * - `RECEIVING_HISTORIES`: each history as data. Session 1 is online and takes
+ * the listed steps. Session 2 setup is the wait and the upstream behavior
+ * (premise control, not an outcome).
+ * - `createReceivingSource`: the on-demand source. It records every row write,
+ * delete, and settled load into an event log, and it controls the upstream
+ * behavior the history asks for.
+ * - `recordSession1` and `recordSession2`: the production entry points. They
+ * run one session over a host's persistence and return what was recorded.
+ * They never judge the record.
+ * - `recordReceivingHistory`: both sessions in one process, for in-process
+ * hosts.
+ *
+ * Out-of-process hosts (the OPFS page, the Electron renderer, the Cloudflare
+ * Durable Object) import this module into their bundle, call the recorders,
+ * and report the records back. Their test files pass each record to the
+ * shared judge.
+ *
+ * This module is platform-neutral: it imports only `@tanstack/db` and the
+ * persistence core, and it uses no test framework.
+ *
+ * ## Production path and observations
+ *
+ * Each session builds a Collection with `persistedCollectionOptions` over the
+ * host's persistence and runs the query's `preload()` with the history's wait.
+ * Session 1 answers its loads through the source, so the rows in storage come
+ * only from the wrapper.
+ *
+ * Session 2 records two checkpoints of the query: `beforeDeadline` 450 ms
+ * after the upstream load is forwarded, and `afterDeadline` after polling up
+ * to 2 500 ms for the query to leave `loading`. Each checkpoint carries its
+ * elapsed time since the forward, so the judge can decide whether the wait had
+ * passed.
+ *
+ * ## Limits
+ *
+ * The law covers only the default single-process coordinator, so every host
+ * here uses it. A framework host observes its own signal (a hook's readiness
+ * or rendered output) and records only what that signal shows. The histories
+ * cover one filter and one ordered window.
+ */
+import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db'
+import { persistedCollectionOptions } from '../../src'
+import { A1, A2, A3 } from './persisted-demand-coverage-receiving-histories'
+import type { Collection, LoadSubsetOptions } from '@tanstack/db'
+import type { PersistedCollectionPersistence } from '../../src'
+import type {
+ CoverageRecord,
+ OfflineUpstream,
+ QueryObservation,
+ QuerySpec,
+ SourceEvent,
+} from '../persisted-demand-coverage-model-oracle'
+import type {
+ ReceivingHistory,
+ Row,
+ Session2Upstream,
+ Shape,
+} from './persisted-demand-coverage-receiving-histories'
+
+// The before-wait checkpoint. It is under half the ordinary wait, so the wait
+// has not passed there, and it is late enough for a framework to show a
+// settlement that an upstream failure produced: React Suspense holds a fallback
+// reveal for about 300 ms.
+const CHECKPOINT_MS = 450
+const AFTER_DEADLINE_POLL_MS = 2500
+const FORWARD_TIMEOUT_MS = 10_000
+const COLLECTION_ID = `persisted-demand-coverage-receiver`
+
+export * from './persisted-demand-coverage-receiving-histories'
+// The model types a receiving driver records with.
+export type {
+ CoverageRecord,
+ QueryObservation,
+ SourceEvent,
+} from '../persisted-demand-coverage-model-oracle'
+
+export type UpstreamBehavior = `hold` | `reject` | `answer-at-once`
+
+export type ReceivingSource = {
+ collection: Collection
+ // Loads the source received, in arrival order.
+ received: Array
+ pending: () => number
+ // Writes rows in one sync transaction, then settles every held load.
+ answer: (rows: ReadonlyArray) => Promise
+ write: (rows: ReadonlyArray) => Promise
+ remove: (rows: ReadonlyArray) => Promise
+}
+
+// One checkpoint of the offline query, and the milliseconds since its load
+// started (the `preload()` call).
+export type ReceivingCheckpoint = {
+ observed: QueryObservation
+ sinceLoadStartMs: number
+}
+
+export const QUERY_NAME = `demand`
+
+export type Session1Record = { events: ReadonlyArray }
+
+export type Session2Record = {
+ // The loads the offline source received for the query, in arrival order.
+ sent: ReadonlyArray
+ beforeDeadline: ReceivingCheckpoint
+ afterDeadline: ReceivingCheckpoint
+}
+
+export type ReceivingSession = {
+ persistence: PersistedCollectionPersistence
+ close: () => Promise
+}
+
+export const sleep = (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms))
+
+export async function until(
+ predicate: () => boolean,
+ timeoutMs: number,
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (!predicate()) {
+ if (Date.now() > deadline) return false
+ await sleep(10)
+ }
+ return true
+}
+
+/**
+ * The upstream behavior a session 2 history asks for. `hold` keeps each load
+ * pending, as an offline network does. `reject` rejects it at once, as a
+ * fetch does when the device is offline.
+ */
+export function sourceBehaviorFor(
+ upstream: Session2Upstream,
+): UpstreamBehavior {
+ return upstream === `never-answers` ? `hold` : `reject`
+}
+
+/**
+ * An on-demand source whose upstream the host controls. Every row write,
+ * delete, and settled load goes into `events`, so the judge can replay what
+ * the source did. `answer-at-once` writes `answerRows` and resolves, for a
+ * framework session that renders through its own hook.
+ */
+export function createReceivingSource(options: {
+ persistence: PersistedCollectionPersistence
+ waitMs: number
+ behavior: UpstreamBehavior
+ events: Array
+ answerRows?: ReadonlyArray
+}): ReceivingSource {
+ const received: Array = []
+ const pending: Array<{ load: LoadSubsetOptions; resolve: () => void }> = []
+ let apply:
+ | ((type: `insert` | `delete`, rows: ReadonlyArray) => unknown)
+ | undefined
+ const collection = createCollection(
+ persistedCollectionOptions({
+ id: COLLECTION_ID,
+ getKey: (row) => row.id,
+ syncMode: `on-demand`,
+ initialRender: {
+ strategy: `network-first`,
+ networkTimeoutMs: options.waitMs,
+ },
+ persistence: options.persistence,
+ sync: {
+ sync: (params) => {
+ apply = (type, rows) => {
+ params.begin()
+ for (const value of rows) params.write({ type, value })
+ for (const row of rows) {
+ options.events.push(
+ type === `insert`
+ ? {
+ type: `rows`,
+ rows: [[row.id, { group: row.group, rank: row.rank }]],
+ }
+ : { type: `delete`, id: row.id },
+ )
+ }
+ return params.commit()
+ }
+ return {
+ loadSubset: (load: LoadSubsetOptions) => {
+ received.push(plainLoadSnapshot(load))
+ if (options.behavior === `reject`) {
+ return Promise.reject(new Error(`upstream offline`))
+ }
+ if (options.behavior === `answer-at-once`) {
+ return Promise.resolve(
+ apply?.(`insert`, options.answerRows ?? []),
+ ).then(() => {
+ options.events.push({
+ type: `settled`,
+ load: plainLoadSnapshot(load),
+ current: true,
+ })
+ })
+ }
+ return new Promise((resolve) => {
+ pending.push({ load, resolve })
+ })
+ },
+ }
+ },
+ },
+ }),
+ )
+ return {
+ collection,
+ received,
+ pending: () => pending.length,
+ answer: async (rows) => {
+ await apply?.(`insert`, rows)
+ for (const { load, resolve } of pending.splice(0)) {
+ options.events.push({
+ type: `settled`,
+ load: plainLoadSnapshot(load),
+ current: true,
+ })
+ resolve()
+ }
+ },
+ write: async (rows) => {
+ await apply?.(`insert`, rows)
+ },
+ remove: async (rows) => {
+ await apply?.(`delete`, rows)
+ },
+ }
+}
+
+// The query's public signal, in the judge's vocabulary. A status the judge
+// does not name is a driver error, so it throws rather than being guessed.
+export function observeQuery(
+ query: ReturnType,
+): QueryObservation {
+ if (query.status === `ready`) {
+ return { status: `ready`, rows: rowIds(query.toArray) }
+ }
+ if (query.status === `error` || query.status === `loading`) {
+ return { status: query.status }
+ }
+ throw new Error(`unexpected query status ${query.status}`)
+}
+
+export function queryFor(shape: Shape, source: Collection) {
+ return shape === `filter`
+ ? createLiveQueryCollection((q) =>
+ q.from({ row: source }).where(({ row }) => eq(row.group, `a`)),
+ )
+ : createLiveQueryCollection((q) =>
+ q
+ .from({ row: source })
+ .where(({ row }) => eq(row.group, `a`))
+ .orderBy(({ row }) => row.rank)
+ .limit(2),
+ )
+}
+
+/** The ids of rows, sorted: the only row form the record keeps. */
+export function rowIds(rows: ReadonlyArray<{ id: string }>): Array {
+ return rows.map((row) => row.id).sort()
+}
+
+// Session 1 is online. Each load is answered by the source, so the rows in
+// storage come only from the wrapper.
+async function loadAndAnswer(
+ source: ReceivingSource,
+ query: ReturnType,
+ rows: ReadonlyArray,
+): Promise {
+ const preloaded = query.preload()
+ const state = { settled: false }
+ void preloaded.then(
+ () => (state.settled = true),
+ () => (state.settled = true),
+ )
+ for (let i = 0; i < 100 && !state.settled; i++) {
+ if (source.pending() > 0) await source.answer(rows)
+ await sleep(10)
+ }
+ await preloaded
+ // Premise witness: session 1 really loaded the demand online, so the
+ // history has coverage to test. This is not a verdict on the law.
+ const held = rowIds(query.toArray).join(`,`)
+ if (held !== `a1,a2`) {
+ throw new Error(`session 1 held ${held}, expected a1,a2`)
+ }
+}
+
+/**
+ * Runs session 1 of a history over `persistence` and returns its source log.
+ * The caller owns the persistence and closes it afterwards.
+ */
+export async function recordSession1(
+ persistence: PersistedCollectionPersistence,
+ history: ReceivingHistory,
+): Promise {
+ const events: Array = []
+ const source = createReceivingSource({
+ persistence,
+ waitMs: 0,
+ behavior: `hold`,
+ events,
+ })
+ const query = queryFor(history.shape, source.collection)
+ const loadRows = history.shape === `filter` ? [A1, A2] : [A1, A2, A3]
+ try {
+ for (const step of history.session1) {
+ if (step.kind === `load`) await loadAndAnswer(source, query, loadRows)
+ if (step.kind === `remove`) await source.remove([step.row])
+ if (step.kind === `write`) await source.write([step.row])
+ }
+ await sleep(50)
+ return { events }
+ } finally {
+ await query.cleanup()
+ await source.collection.cleanup()
+ }
+}
+
+/**
+ * A transport-safe copy of a load, for hosts that send records out of their
+ * bundle (a page, a worker, or a renderer). The IR can hold cycles, which JSON
+ * rejects, so the copy keeps each object once per branch and drops a back
+ * reference. The demand key and the judge read only the fields the copy keeps.
+ */
+export function plainLoadSnapshot(
+ value: T,
+ ancestors = new Set