From a90c11b9bb5eaa654a9276dac3dd24077d2d6466 Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 09:51:41 -0600 Subject: [PATCH 001/106] docs: plan final initial errors and the source health channel Co-authored-by: Isaac --- .../HEALTH-CANDIDATE.md | 153 ++++++++ .../designs/source-failure-and-health/PLAN.md | 277 +++++++++++++++ .../source-failure-and-health/README.md | 161 +++++++++ .../SourceFailure.tla | 183 ++++++++++ .../SourceHealth.tla | 113 ++++++ .../USER-DOCS-DRAFT.md | 248 +++++++++++++ .../model-d-source-error-grammar.patch | 214 ++++++++++++ .../pr-2129-review-probes.test.ts.txt | 329 ++++++++++++++++++ .../source-failure-and-health/health-safe.cfg | 12 + .../health-weak-clears-rows.cfg | 8 + .../health-weak-drop-healthy.cfg | 8 + .../health-weak-flips-status.cfg | 8 + .../health-weak-framework-clears.cfg | 8 + .../health-weak-no-relay.cfg | 8 + .../source-failure-and-health/safe.cfg | 22 ++ .../weak-clear-late.cfg | 14 + .../weak-detach-waits.cfg | 14 + .../weak-framework-retry.cfg | 14 + .../source-failure-and-health/weak-heal.cfg | 14 + .../weak-lazy-terminal.cfg | 14 + .../source-failure-and-health/weak-poison.cfg | 14 + .../weak-reject-on-retry.cfg | 14 + .../weak-silent-give-up.cfg | 14 + .../source-failure-and-health/weak-stale.cfg | 14 + 24 files changed, 1878 insertions(+) create mode 100644 docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md create mode 100644 docs/contributing/designs/source-failure-and-health/PLAN.md create mode 100644 docs/contributing/designs/source-failure-and-health/README.md create mode 100644 docs/contributing/designs/source-failure-and-health/SourceFailure.tla create mode 100644 docs/contributing/designs/source-failure-and-health/SourceHealth.tla create mode 100644 docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md create mode 100644 docs/contributing/designs/source-failure-and-health/evidence/model-d-source-error-grammar.patch create mode 100644 docs/contributing/designs/source-failure-and-health/evidence/pr-2129-review-probes.test.ts.txt create mode 100644 docs/contributing/designs/source-failure-and-health/health-safe.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/health-weak-clears-rows.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/health-weak-drop-healthy.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/health-weak-flips-status.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/health-weak-framework-clears.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/health-weak-no-relay.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/safe.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-clear-late.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-detach-waits.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-framework-retry.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-heal.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-lazy-terminal.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-poison.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-reject-on-retry.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-silent-give-up.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/weak-stale.cfg diff --git a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md new file mode 100644 index 0000000000..98e980d044 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md @@ -0,0 +1,153 @@ +# Candidate: the source health channel and live-query error tree + +Status: candidate design, version 1. It came from a Field Lab design-grammar +run and has not been accepted. + +## Success standard (frozen, user-confirmed) + +- H1 After the first ready, a sync failure is metadata. Status stays `ready`; + data stays, even if increasingly stale. +- H2 Before the first ready, the gate is unchanged: a final failure is + `markError`, reaches every consumer, and readiness waits reject. +- H3 The framework never heals or retries. Health reports what the source last + said; only the source clears its failure by reporting healthy sync again. +- H4 A live query carries its sources' health, through joins, includes, and + nested live queries. +- H5 Hooks expose health without changing data, `isReady`, or `status`. No + flicker; Suspense does not throw after ready. +- H6 (open) A subset load for new demand that fails after ready is decided + per request: it affects the requesting query, not the source. +- H7 One adapter API reports post-ready failures, replacing today's mix of + ready-to-`error` transitions and adapter utilities. +- H8 query-db's `utils.lastError`, `isError`, and `errorCount` keep working. +- H9 An Effect hears about health changes after ready instead of being + disposed. +- H10 A persisted collection keeps serving local rows when upstream sync fails + after ready; health says upstream is degraded. +- H11 `useLiveQuery` returns an `errors` structure describing the tree of + collections it depends on. It always has the same shape, shows whether each + node is in error, and carries detailed error info when in error, so apps + can parse it. + +Prior law (frozen earlier): a source either keeps trying (loading), becomes +ready, or reports a final error for its sync attempt; the framework never +heals an attempt; recovery is app-level (`cleanup()` then `preload()`). + +## Source trace (current code) + +- Status is the only propagated channel: live queries handle sources' + `status:change` (`db/src/query/live/collection-config-builder.ts:1104`); + hooks derive `isError` from status (`react-db/src/useLiveQuery.ts`). +- Electric (`electric.ts:2628, 2708`) and PowerSync (`powersync.ts:826`) + switch a ready collection to `error` after ready; PowerSync then heals with + `markReady` (`powersync.ts:813`). +- query-db exposes `utils.lastError`, `isError`, `errorCount`, reset after a + successful result (`query-db-collection/src/query.ts:325-332`). +- Hooks already expose a parallel metadata channel for persistence: + `persistedStatus`, `persistedError` (`react-db/src/useLiveQuery.ts`). +- A live query's direct sources come from `extractCollectionSources(query)` + (`collection-config-builder.ts:197`); a source can itself be a live-query + Collection. + +## The candidate + +### Two independent axes + +- **Gate (`status`)**: `idle | loading | ready | error | cleaned-up`. Decided + only by the initial sync of an attempt. Unchanged from today except that + `error` is final for the attempt. +- **Health**: `healthy | error`, plus error info. Meaningful after the first + ready. Only the source changes it. + +### Adapter API (sync function params) + +```ts +sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy }) => { ... } +``` + +- `markError(error)` before the first ready: the final initial failure (gate). +- `reportError(error)` after the first ready: health becomes `error` with this + cause. Status and rows do not change. +- `reportHealthy()`: the source has synced successfully again; health becomes + `healthy`. +- `markError(error)` after the first ready is forwarded to `reportError(error)` + with a development warning, so existing adapters keep working while they + migrate. +- Calling `markReady()` after `markError()` in one attempt throws (contract + violation). + +### Collection surface + +```ts +collection.health // { isError: boolean, error: CollectionErrorInfo | null } +collection.on('health:change', (event) => { ... }) + +type CollectionErrorInfo = { + phase: 'initial' | 'sync' | 'request' + name: string + message: string + cause: unknown + at: number // epoch milliseconds +} +``` + +`phase: 'initial'` mirrors a gate `error`; `phase: 'sync'` is a post-ready +health report; `phase: 'request'` is a subset load failure (H6). + +### The error tree + +Every live-query Collection exposes `errors`, and every framework hook returns +it beside `data`: + +```ts +type ErrorTreeNode = { + collectionId: string + kind: 'source' | 'live-query' + status: CollectionStatus // the node's gate + isError: boolean // this node: gate error, health error, or request error + hasErrors: boolean // this node or any descendant + error: CollectionErrorInfo | null // detail when isError + sources: Array // dependencies; [] for a base source +} +``` + +- The root is the live query itself. Its `sources` are the query's + collections in query order (`from`, then joins, then includes), each + recursing through nested live queries. +- The shape is fixed by the query: every dependency is present whether or not + it is in error. A collection reached by two paths appears once per path. +- Nodes mirror their collections in the same update as the change. +- A post-ready request failure (H6) sets `error` with `phase: 'request'` on + the live-query node that issued the request. The source stays healthy. +- `isError` on the hook result keeps its current meaning: the gate is + `error`. `errors.hasErrors` reports anything anywhere in the tree. + +### Effects + +`createEffect({ onSourceHealth?: (errors: ErrorTreeNode) => void })`. After +the first ready a health change calls it; the Effect is not disposed. Before +the first ready, a gate error still disposes the Effect through +`onSourceError`. + +### Adapters + +- Electric and PowerSync replace post-ready `markError` with `reportError` and + call `reportHealthy` after their next successful sync. PowerSync's + `markReady` recovery becomes `reportHealthy`. +- query-db keeps `utils.lastError/isError/errorCount` and also calls + `reportError`/`reportHealthy`; it no longer calls `markReady` from `error`. +- Persisted collections: local storage failures stay on `persistedError`; + upstream failures after ready are the collection's health. + +### Recovery + +App-level only. `cleanup()` then `preload()` starts a fresh attempt and resets +health to `healthy`. + +## Model check + +`SourceHealth.tla` checks stable shape, mirroring through a nested live query, +only-the-source-clears, gate independence after ready, and that a gate error +is explained by a tree node. Five weakened designs each produce their +counterexample. The model has two sources and one nested live query; it does +not cover a collection reached by two paths. diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md new file mode 100644 index 0000000000..81b7bffe44 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -0,0 +1,277 @@ +# Plan: final initial errors and the source health channel + +Status: planned, not started. Do this after #2126, #2127, and #2128 merge. +It ships as one new pull request, because it is a breaking change and a +significant product change. It replaces #2129. + +Branch: `feat/source-health-channel` (worktree +`.worktrees/source-health-channel`). + +## 1. Decisions (maintainer, 2026-10-13) + +1. **A retry that succeeds is a success.** While a source retries, it reports + nothing. The Collection stays `loading`. (Already in #2127 for Electric.) +2. **`markError` is final for its sync attempt.** A source calls it only when + it gives up. The framework never heals an attempt: no `error` → `ready`. +3. **Recovery is the app's job.** `cleanup()` then `preload()`, a refetch, or a + user refresh starts a fresh attempt. The framework adds no retries. +4. **A final initial error reaches every consumer with its cause**: readiness + waits and one-shot reads reject, live queries enter error for every shape, + Suspense throws, Effects are disposed through `onSourceError`, and + subscribers get `lastError` and `loadSubset:error`. +5. **`status` is only the initial gate.** After the first ready, a sync + failure is metadata on the Collection. The data stays, even if it grows + stale. +6. **`useLiveQuery` returns an `errors` tree** for every collection the query + depends on. It always has the same shape, shows whether each node is in + error, and carries detailed error info, so apps can parse it. +7. A buggy source or an app with endless retries can still look stuck. That is + a plugin or app problem, not a framework guarantee. +8. Generated API reference docs are produced on release by an automated pull + request. Do not edit them in this pull request. + +Rationale and controls: `README.md` (failure-law grammar, `SourceFailure.tla`, +10 configurations) and `HEALTH-CANDIDATE.md` plus `SourceHealth.tla` (health +channel, 6 configurations). The user-facing target is `USER-DOCS-DRAFT.md` +(candidate v2). + +## 2. Target design (candidate v2) + +Summarized from `USER-DOCS-DRAFT.md`; that file is the source of truth for +names and shapes. + +- **Two axes.** Gate: `status` (`idle | loading | ready | error | + cleaned-up`), decided only by an attempt's initial sync. Health: per + Collection, changed only by its source after the first ready. +- **Adapter API.** Sync params gain `reportError(error, { final })` and + `reportHealthy()`. `markError` is for giving up before the first ready. + `markError` after the first ready is treated as + `reportError(error, { final: true })` with a development warning. + `markReady()` after `markError()` in one attempt throws. +- **Collection surface.** `collection.health` and a `health:change` event. +- **Error info.** `{ phase: 'initial' | 'sync' | 'request', final, name, + message, code?, at, cause }`. Everything except `cause` is JSON-safe. +- **Error tree.** `ErrorTreeNode = { alias, collectionId, kind: 'source' | + 'live-query' | 'include', status, isError, hasErrors, error, instances?, + sources }`. + - One node per query alias, in query order, fixed by the query definition + (fracture F-a). A self-join appears once per alias. + - An include has one node for all its per-row child Collections, with + `instances: { total, inError }`. + - Nested live queries recurse. + - Healthy nodes are present with `isError: false`. +- **Final flag** (fracture F-b). `final: true` means the source stopped + trying; only a restart clears it. `reportHealthy()` cannot clear it. +- **Request errors** (fracture F-c). A load for new demand that fails after + ready sets `phase: 'request'` on the live-query node that issued it. The + source stays healthy. It clears when that demand later settles or is + released. +- **Hooks.** Return `errors` beside `data`. `isError` keeps meaning "the + initial gate is `error`". Docs lead with `errors.hasErrors` for stale data. +- **Effects.** `onHealthChange(errors)` after the first ready; no disposal. + Initial gate errors still dispose through `onSourceError`. +- **SSR.** `errors` is not dehydrated; the client builds its own tree. + +## 3. Work breakdown + +### 3.1 Core: `packages/db` + +- [ ] `collection/lifecycle.ts`: remove `error` → `ready` from the valid + transitions. `markReady()` after `markError()` in the same attempt + throws. Keep `error` → `cleaned-up` and `idle`. +- [ ] `collection/lifecycle.ts` / `sync.ts`: add health state, `reportError`, + `reportHealthy`, the `final` rule, and reset on cleanup and restart. + Scope the callbacks to the sync-run epoch so a stale attempt cannot + report. +- [ ] `collection/events.ts`: add `health:change` to `AllCollectionEvents`. + Define the payload (previous, current) and the ordering: same tick, + after `status:change` when both change (the ordering is still to be + checked). +- [ ] `types.ts`: `SyncConfig` params, `CollectionErrorInfo`, + `ErrorTreeNode`, and the `Collection.health` and `errors` types. +- [ ] `markError` after the first ready: forward to + `reportError(error, { final: true })` and warn in development. +- [ ] `query/live/collection-config-builder.ts`: + - [ ] a source entering `error` before the live query's first ready stays + a gate error for every shape (root, join, include alike); + - [ ] remove the `erroredSourceIds` recovery path (line ~1131) and its + status-based healing; + - [ ] post-ready source health does not change the live query's status or + rows; + - [ ] build and maintain the `errors` tree: per-alias nodes, include + roll-ups, nested live-query relay, and request errors; + - [ ] `failDemand` and `recordSubsetError`: before ready, terminal (gate); + after ready, a request error on the live-query node, cleared on + settle or release. +- [ ] `collection/subscription.ts`: keep `lastError` and `loadSubset:error` + for every rejected load (#2129 review C8). Do not detach demand for + recovery. +- [ ] `query/effect.ts`: `onHealthChange`; no disposal after the first + ready. +- [ ] `live-query-adapter.ts` and `live-query-observer.ts`: add `errors` to + the snapshot and status flags, so every framework adapter gets it from + one place. Keep `isError` as the gate. +- [ ] `query/live/pooled-live-query*`: decide and implement health for + pooled views that share sources (open question). +- [ ] `query/live/ARCHITECTURE.md`: replace #2129's law 16 with the final + error law and the health channel law. Update the cleanup and restart + section. + +### 3.2 Adapters + +- [ ] **Electric** (`electric-db-collection/src/electric.ts`, 9 call + sites): + - [ ] post-ready `markError` at `:2628` and `:2708` becomes `reportError` + (`final` per site); + - [ ] `reportHealthy()` after the next successful up-to-date following a + problem; + - [ ] the SDK give-up path: before the first ready, `markError`; after it, + `reportError(..., { final: true })`. This extends #2127's E1 fix. +- [ ] **query-db** (`query-db-collection/src/query.ts`, 6 sites): + - [ ] a failed refetch after ready calls `reportError`; a successful + result after a problem calls `reportHealthy()`; + - [ ] `:2556`: no `markReady()` when the status is `error` (V1); + - [ ] keep `utils.lastError`, `utils.isError`, and `utils.errorCount`, and + state how they relate to health (assay compromise). +- [ ] **PowerSync** (`powersync-db-collection/src/powersync.ts`, 6 sites): + a failed tracking rebuild at `:826` calls `reportError`; a successful + rebuild at `:813` calls `reportHealthy()` instead of `markReady()` (V2). +- [ ] **TrailBase** (`trailbase-db-collection/src/trailbase.ts`, 4 sites): + post-ready stream failures call `reportError`. Check them against the + #2126 fix. +- [ ] **RxDB** (`rxdb-db-collection/src/rxdb.ts`, 3 sites): post-ready + failures call `reportError`. Observe what happens to pending loads on a + final error (evidence gap V10). +- [ ] **Persistence** (`db-sqlite-persistence-core/src/persisted.ts`, 19 + sites): + - [ ] forward upstream health; keep local storage failures on + `persistedError`; + - [ ] follower tabs learn the leader's upstream health through the + coordinator (open question); + - [ ] check the browser and Electron hosts, and the node host. +- [ ] Local-only and local-storage Collections: confirm they never need + health, and document it. + +### 3.3 Framework hooks + +- [ ] React, Vue, Solid, Svelte, and Angular return `errors` from every + live-query hook, including infinite-query hooks and + `useLiveSuspenseQuery`. +- [ ] Suspense: throws only for the initial gate, never for post-ready + health. +- [ ] Decide whether `errors` keeps stable node identity for memoization + (open question). + +### 3.4 Tests and oracles + +- [ ] Flip the #1751 healing tests on purpose: each one that asserts + `error` → `ready` becomes "stays `error`; recovery needs a restart". + Search `collection-errors.test.ts`, live-query recovery tests, and + query-db and PowerSync recovery tests. +- [ ] A new oracle owner for the health channel, from `SourceHealth.tla`'s + properties: stable shape, mirroring through nested live queries, only + the source clears, gate independence after ready, gate explained by a + node, `final` not clearable, and request errors that clear on settle or + release. Extend the topology beyond the TLA model: a collection reached + by two aliases, includes with per-row children, and pooled views. +- [ ] Model D (`includes-temporal-oracle.test.ts`): rework the source-error + lane to the final-error law. Start from `evidence/ + model-d-source-error-grammar.patch` (the C4, C5, and C6 grammar + dimensions: truncate during the error, ready → error, exact + acquisitions, and every `source-ready` position). Under the new law, + `source-ready` after `source-error` in one attempt is illegal, so those + histories become "restart, then recover". +- [ ] Shared framework conformance suite (`packages/db/tests/conformance/`): + a health scenario, so all five drivers check `errors` the same way. +- [ ] Adapter witnesses: each adapter reports post-ready failures through + health and keeps its rows. Electric adds SDK give-up cases through the + real SDK (missing headers, retry exhaustion). +- [ ] Mutant calibration for each new law, including the TLA weakenings: + health flips status, nested live query doesn't relay, framework clears, + healthy nodes dropped, failure clears rows, heal on ready, framework + retry. +- [ ] Rerun the #2129 review probes in `evidence/ + pr-2129-review-probes.test.ts.txt` against the new law. Several + expectations change, because recovery now needs a restart. + +### 3.5 Hand-written docs (not generated reference) + +- [ ] `docs/guides/error-handling.md`: rewrite the collection error sections + around the two axes. Remove the contradiction between line ~361 (manual + recovery) and line ~534 (a refetch recovers). +- [ ] `docs/guides/collection-options-creator.md`: the adapter rules from + `USER-DOCS-DRAFT.md` (stay silent while retrying; `markError` only when + giving up before ready; `reportError` and `reportHealthy` after ready; + never drop rows). +- [ ] `docs/guides/live-queries.md`: the `errors` tree and the + `isError` / `errors.hasErrors` callout. +- [ ] `docs/guides/sqlite-persistence.md`, `docs/guides/offline-transactions.md`: + upstream health against `persistedError`. +- [ ] `docs/errors.md`: the `markReady`-after-`markError` contract error, + and any new error code. +- [ ] `docs/collections/electric-collection.md`: `onError` retries are + silent; a give-up is final; post-ready health. +- [ ] `docs/collections/local-storage-collection.md`: check its error-state + wording. +- [ ] `docs/collections/query-collection.md` (if present): how `utils` relate + to health; recovery by restart. +- [ ] Package skills: `packages/db/skills/db-core/custom-adapter/SKILL.md` + (remove "later successful work in the same sync run can call + `markReady()` to recover", line ~349), and the React, Vue, Solid, and + Angular skill files. Confirm with the maintainer that these package + skills are in scope. +- [ ] Package READMEs that mention `isError` (for example + `packages/angular-db/README.md`). +- [ ] `docs/contributing/oracle-coverage.md`: the new health owner, and the + Model D source-error lane. +- [ ] `docs/contributing/oracle-reviews/progressive-source-readiness.md`: + record the decided law and the companion fixes (#2126, #2127, #2128). + #2129 carried this update and is being replaced. + +### 3.6 Release + +- [ ] Changeset: a breaking change for `@tanstack/db` and every adapter that + changes. Include a migration guide: + - an initial error no longer heals; recover with `cleanup()` and + `preload()`; + - after the first ready, failures no longer move a Collection or a live + query to `error`; read `errors`; + - adapter authors: `reportError` and `reportHealthy`; + - `markReady()` after `markError()` now throws. +- [ ] PR body: the decisions above, the design, the evidence, and the + breaking changes. + +## 4. Order of work + +1. Merge #2126, #2127, and #2128 after their review findings. +2. Close #2129 in favor of this pull request, and move its review-record + update here. +3. Core lifecycle and the health channel, with the health oracle first (RED, + then GREEN). +4. Live queries: the gate for every shape, then the error tree, then + request errors. +5. Effects, then the observer and status flags, then the hooks and the + conformance suite. +6. Adapters, one at a time, each with witnesses. +7. Docs, then the changeset and the PR body, then review and CI. + +## 5. Open questions + +- The `health:change` ordering relative to `status:change` (proposed: same + tick, after it). Model-check it before relying on it. +- How follower tabs learn the leader's upstream health. +- Health for pooled live queries that share sources. +- Stable node identity in `errors` for memoization. +- Which semver bump the packages get, given the 0.x line. +- Whether the shipped package skills are in scope for edits. + +## 6. Carried from the reviews + +- #2129 C1, C2, C3, C7, C9, C10, and S1 concerned recovery machinery that this + design removes. Recheck them against the new law using the probes in + `evidence/`. +- #2129 C8 (keep reporting `lastError` and `loadSubset:error`) is decided + yes. It is in 3.1. +- #2129 C4, C5, and C6 (Model D grammar gaps) carry over in 3.4. +- #2127 E1 (an SDK give-up must be reported) is fixed in #2127 for the + initial gate. The post-ready half is in 3.2. diff --git a/docs/contributing/designs/source-failure-and-health/README.md b/docs/contributing/designs/source-failure-and-health/README.md new file mode 100644 index 0000000000..c16e768249 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/README.md @@ -0,0 +1,161 @@ +# Source failure law: design grammar and TLA+ control + +This directory holds a Field Lab design-grammar run on the source failure law +that the maintainer chose on 2026-10-13. A source either keeps trying, becomes +ready, or reports a final error for its sync attempt. The framework never heals +an attempt. Recovery is the app's job and starts a fresh attempt. + +## Frozen preservation list + +U marks a user-required property, S a source-stated property (mostly from +#1751), and I an analyst-inferred property. The user confirmed the list and +corrected F8. + +| ID | Property | Basis | +| --- | --- | --- | +| F1 | While a source retries, it reports `loading`, not `error`. A retry that succeeds is a success. | U | +| F2 | `markError` is final for its sync attempt. The framework never moves it back to `ready`. | U | +| F3 | Recovery is the app's job and starts a fresh attempt: cleanup, then preload, a refetch, or a user refresh. The fresh attempt requests all demand again. | U | +| F4 | A final error reaches every consumer with its cause: readiness waits and one-shot reads reject, live queries enter error for every shape, Suspense throws, an Effect reports `onSourceError`, and subscribers get `lastError` and `loadSubset:error`. | U | +| F5 | `ready` means a usable snapshot exists. A failure never publishes a false or empty ready. | S | +| F6 | A failure after the first ready does not discard visible data. Open: whether such a failure moves the status to `error`. | S | +| F7 | Cleanup and restart revoke the old attempt. A stale attempt cannot mark the new one. | S | +| F8 | The framework leaves no wait pending forever: every wait resolves, rejects with the final cause, or ends quietly on cleanup. A buggy source or an app with endless retries can still look stuck; that is a plugin or app problem. | I, corrected by U | +| F9 | Retry policy belongs to the source or the app. The framework adds no retries. | U | +| F10 | Every source maps its native signals to three outcomes: still trying, ready, or final error, including give-ups inside an SDK. | I | +| F11 | Every consumer of one source observes the same outcome at the same point. | I | +| F12 | A request that fails while its source is healthy fails its requester, not the source. | I | + +## Model layer: candidate grammar + +Primitives (each traced to the frozen list): + +- **G1 attempt:** one sync run, from start to cleanup. It is the unit of + finality (F2, F7). +- **G2 outcome:** the source's signal, one of `trying`, `ready`, or + `final-error` (F1, F2, F10). +- **G3 snapshot:** usable visible rows (F5, F6). +- **G4 consumer:** a readiness wait, a live query (root or lazy), an Effect, or + a subscriber (F4, F11). +- **G5 request:** one demand's load. It resolves, fails locally, or fails with + its source (F12). +- **G6 app restart:** the only way to begin a new attempt (F3, F9). + +Rules: + +- **R1** `trying` keeps the status `loading` and fails no consumer (F1). +- **R2** `final-error` is absorbing within its attempt (F2). +- **R3** `final-error` reaches every consumer in the same step, with its cause. + Pending requests reject with that cause (F4, F8). +- **R4** `ready` requires a snapshot (F5). +- **R5** A failure after ready keeps the snapshot (F6). Whether the status + becomes `error` is **unresolved** (conflict C1). +- **R6** A local request failure fails only its requester (F12). +- **R7** Signals from a cleaned attempt are inert (F7). +- **R8** An adapter translates every give-up inside its SDK into `final-error` + (F10). +- **R9** Only the app begins an attempt (F3, F9). + +Active overlap and conflict: + +- **C1 (unresolved):** a failure after ready sits where R3 and R5 overlap. R3 + propagates a final error to every consumer; R5 keeps the visible snapshot. + PowerSync's comment at `powersync.ts:823` states the R3 side: "Do not leave + healthy consumers ready against a stale source." #1751 and query-db state + the R5 side: a refetch failure keeps the collection `ready`. The grammar + keeps both branches (adjacent forms AF1 and AF2). +- **C2 (low stakes):** Electric rejects loads and then calls `markError` in + the same turn, so a rejection can arrive before the status. Under R2 and R3 + both orders end with every consumer in terminal error. Only the reported + cause differs. + +## Evidence layer + +### Reconstruction against the current code + +Rebuilding the current arrangement from the grammar exposes these departures. +Each is an observation with a source pointer. None was invented to fit the +grammar. + +| ID | Departure | Rule | Pointer | +| --- | --- | --- | --- | +| V1 | query-db calls `markReady()` after any later successful application, so a refetch heals an initial error. | R2 | `query-db-collection/src/query.ts:2556` | +| V2 | PowerSync marks `error` after ready when a tracking rebuild fails, then marks `ready` when a later rebuild succeeds. | R2, R1 | `powersync-db-collection/src/powersync.ts:813, 826` | +| V3 | A live query leaves error when its errored sources become ready. | R2, R9 | `db/src/query/live/collection-config-builder.ts:1131` | +| V4 | The lifecycle allows `error` → `ready`. | R2 | `db/src/collection/lifecycle.ts:106` | +| V5 | The docs describe healing. | R2 | `docs/guides/error-handling.md:534`; `db/skills/db-core/custom-adapter/SKILL.md:349` | +| V6 | On `main`, Electric marks `error` before the `onError` retry decision. | R1 | `electric-db-collection/src/electric.ts:1970` (fixed by #2127) | +| V7 | With #2127, an SDK give-up after a retry object is not reported. | R8 | #2127 review finding E1 | +| V8 | #2129 detaches waiting loads on a source error instead of rejecting them. | R3 | #2129 `subscription.ts` `detachForErrorRecovery` | +| V9 | Lazy demand fails for good, while a root query follows the source status. | F11 | `collection-config-builder.ts:448` vs `:1131` | +| V10 | RxDB leaves pending loads on a final error unobserved. | R3 (evidence gap) | `rxdb-db-collection/src/rxdb.ts:283` | + +The survey found that post-ready failures keep the last snapshot readable in +every adapter. Electric also calls `markError` after ready +(`electric.ts:2628, 2708`), which places it on the R3 side of C1. + +### Ablation and model check + +`SourceFailure.tla` encodes G1 to G6 as variables and R1 to R9 as actions and +invariants. TLC 2.19 (rev 5a47802) ran with `-deadlock` and `MaxAttempts = 3`. + +| Configuration | Result | +| --- | --- | +| `safe` | no error, 31 distinct states | +| `weak-heal` (ablate R2) | `NoHeal` violated: Start, SourceReady, FinalError, SourceReady | +| `weak-reject-on-retry` (ablate R1) | `RetryIsNotFailure` violated: Start, TransientError | +| `weak-detach-waits` (ablate R3) | `PassThrough` violated: Start, TransientError, FinalError | +| `weak-clear-late` (ablate R5's snapshot clause) | `ReadyMeansRows` violated: Start, SourceReady, LateFailure | +| `weak-poison` (ablate R6) | `RequestFailureIsLocal` violated: Start, RequestFails | +| `weak-stale` (ablate R7) | `ErrorIsOwn` violated: Start, TransientError, FinalError, AppRestart, TransientError, StaleCallback | +| `weak-silent-give-up` (ablate R8) | `StoppedSourceReported` violated: Start, SdkGivesUp | +| `weak-framework-retry` (ablate R9) | `OnlyAppRestarts` violated: Start, FinalError, AutoRestart | +| `weak-lazy-terminal` (ablate F11 under healing) | `ConsumersAgree` violated: Start, TransientError, FinalError, SourceReady | + +Every rule is load-bearing: removing any one produces its expected +counterexample. The lazy asymmetry (V9) violates agreement only when healing is +also allowed. Under R2, root and lazy demand both end in terminal error, so +V9's bug class disappears instead of needing recovery code. + +Encoding receipt: in the first `weak-poison` run, `RequestFails` assigned +`live'` twice, so the poison branch was disabled and TLC found no error. The +action now assigns `live'` once in each branch. + +### Range, negative case, and limits + +- **Range:** untested. No independent marginal case was supplied. +- **Negative case:** a collection that silently returns from `error` to + `ready` when the network comes back ("stale-while-revalidate healing"). The + grammar must not generate it, and R2 excludes it. +- **Limits:** one source, two live-query shapes, one wait, and at most three + attempts. No fairness and no liveness. TLC checks the rules' internal + consequences, not that production follows them. + +## Adjacent forms + +- **AF1 degraded ready (C1, R5 branch):** a failure after ready keeps the + status `ready` and the rows, and exposes the error through utilities such as + query-db's `lastError`. Route: rule combination R4 + R5. Loss: consumers that + must not act on stale data get no status signal. +- **AF2 final after ready (C1, R3 branch):** a final failure after ready moves + the status to `error` and keeps the last rows readable while consumers report + the error. Route: rule combination R3 + R5. Loss: every dependent query + enters error even when its rows were still correct. +- **AF3 app retry helper (G6 + R9):** one call restarts a failed source and its + dependent live queries, replacing the manual sequence in + `error-handling.md`. Route: modular augment over the existing cleanup and + restart interface. Cost: a new public API. + +## Process record + +- Source and preservation list frozen with the user's correction to F8. +- Source facts: an Explore survey of every `markError` and `markReady` call + site, plus direct reads of `query.ts:2556` and `powersync.ts:805–830`. The + survey missed V1; the direct read found it. +- Every model element traces to a frozen item, as listed above. + +Run a configuration from this directory: + +```sh +~/.claude/skills/field-lab/scripts/tlc . -deadlock -config safe.cfg SourceFailure +``` diff --git a/docs/contributing/designs/source-failure-and-health/SourceFailure.tla b/docs/contributing/designs/source-failure-and-health/SourceFailure.tla new file mode 100644 index 0000000000..cba2a11d75 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/SourceFailure.tla @@ -0,0 +1,183 @@ +---------------------------- MODULE SourceFailure ---------------------------- +(***************************************************************************) +(* The source failure law: a source either keeps trying (loading), becomes *) +(* ready, or reports a final error for its sync attempt. The framework *) +(* never heals an attempt; the app recovers by starting a fresh attempt *) +(* (cleanup, then preload). Each element names the frozen property (F1 to *) +(* F12) that it encodes; README.md holds the trace table. *) +(* *) +(* One source serves a readiness wait (preload, stateWhenReady, *) +(* toArrayWhenReady, queryOnce), a direct live query, a live query over a *) +(* lazy join or include, and a skipInitial Effect. Consumers react in the *) +(* same step as the source signal, as their status listeners do. *) +(***************************************************************************) +EXTENDS Naturals + +CONSTANTS + MaxAttempts, + \* Weakenings: each encodes one plausible wrong design. + HealOnReady, \* the source may mark ready after its final error (old #1751 healing) + RejectOnRetry, \* a transient error fails consumers while the source retries + LazyTerminal, \* lazy demand fails for good while the root follows status + DetachWaits, \* waiting loads detach for recovery instead of rejecting + SilentGiveUp, \* the SDK gives up without the adapter reporting markError + StaleApplies, \* a callback from a cleaned attempt reaches the new one + PoisonSource, \* a request-specific failure marks the source failed + FrameworkRetry, \* the framework restarts a failed attempt by itself + ClearOnLateFail \* a failure after ready discards the visible snapshot + +Consumers == {"root", "lazy"} + +VARIABLES + attempt, \* current sync attempt number (F7) + status, \* "idle" | "loading" | "ready" | "error" + retrying, \* the source reported a transient error and is retrying (F1, F9) + alive, \* the source is still trying in this attempt (F10) + finalFailed, \* the source reported a final error in this attempt (F2) + rows, \* a usable snapshot is visible (F5, F6) + wait, \* readiness wait: "none" | "pending" | "resolved" | "rejected" | "aborted" + live, \* live query state per consumer: "loading" | "ready" | "error" + effect, \* "active" | "disposed" + reqFailed, \* the root query's own request failed on a healthy source (F12) + byApp \* the last attempt change came from the app (F3, F9) + +vars == <> + +Init == + /\ attempt = 0 /\ status = "idle" /\ retrying = FALSE /\ alive = FALSE + /\ finalFailed = FALSE /\ rows = FALSE /\ wait = "none" + /\ live = [c \in Consumers |-> "loading"] /\ effect = "active" + /\ reqFailed = FALSE /\ byApp = TRUE + +\* A fresh attempt: all demand is requested again and every wait is new (F3). +FreshAttempt(app) == + /\ attempt' = attempt + 1 /\ status' = "loading" /\ alive' = TRUE + /\ retrying' = FALSE /\ finalFailed' = FALSE /\ rows' = FALSE + /\ wait' = "pending" /\ live' = [c \in Consumers |-> "loading"] + /\ effect' = "active" /\ reqFailed' = FALSE /\ byApp' = app + +Start == attempt = 0 /\ FreshAttempt(TRUE) + +\* The source keeps trying: a transient error with a retry (F1). +TransientError == + /\ status = "loading" /\ alive /\ ~retrying + /\ retrying' = TRUE + /\ IF RejectOnRetry + THEN /\ wait' = IF wait = "pending" THEN "rejected" ELSE wait + /\ live' = [c \in Consumers |-> "error"] /\ effect' = "disposed" + ELSE UNCHANGED <> + /\ UNCHANGED <> + +RetrySucceeds == + /\ retrying /\ alive + /\ retrying' = FALSE + /\ UNCHANGED <> + +\* The source establishes a usable snapshot (F5). +SourceReady == + /\ alive /\ ~retrying + /\ (status = "loading") \/ (HealOnReady /\ status = "error") + /\ status' = "ready" /\ rows' = TRUE + /\ wait' = IF wait = "pending" THEN "resolved" ELSE wait + /\ live' = [c \in Consumers |-> + IF (c = "root" /\ reqFailed) \/ (LazyTerminal /\ live[c] = "error" /\ c = "lazy") + THEN live[c] ELSE "ready"] + /\ UNCHANGED <> + +\* A final error reaches every consumer with its cause (F2, F4). +FinalError == + /\ alive /\ status \in {"loading", "ready"} + /\ status' = "error" /\ finalFailed' = TRUE /\ retrying' = FALSE + /\ alive' = HealOnReady + /\ wait' = IF wait = "pending" /\ ~DetachWaits THEN "rejected" ELSE wait + /\ live' = [c \in Consumers |-> "error"] /\ effect' = "disposed" + /\ UNCHANGED <> + +\* The SDK gives up inside the source; the adapter must report it (F10). +SdkGivesUp == + /\ alive /\ status = "loading" + /\ IF SilentGiveUp + THEN /\ alive' = FALSE /\ retrying' = FALSE + /\ UNCHANGED <> + ELSE /\ status' = "error" /\ finalFailed' = TRUE /\ alive' = FALSE + /\ retrying' = FALSE + /\ wait' = IF wait = "pending" /\ ~DetachWaits THEN "rejected" ELSE wait + /\ live' = [c \in Consumers |-> "error"] /\ effect' = "disposed" + /\ UNCHANGED <> + +\* A later refresh failure after ready keeps the snapshot (F6). +LateFailure == + /\ status = "ready" /\ alive + /\ rows' = ~ClearOnLateFail + /\ UNCHANGED <> + +\* One request fails while its source is healthy (F12). +RequestFails == + /\ status \in {"loading", "ready"} /\ alive /\ ~reqFailed + /\ reqFailed' = TRUE + /\ IF PoisonSource + THEN /\ status' = "error" /\ alive' = FALSE + /\ wait' = IF wait = "pending" THEN "rejected" ELSE wait + /\ effect' = "disposed" /\ live' = [c \in Consumers |-> "error"] + ELSE /\ live' = [live EXCEPT !["root"] = "error"] + /\ UNCHANGED <> + /\ UNCHANGED <> + +\* A callback from the cleaned previous attempt arrives (F7). +StaleCallback == + /\ attempt > 1 /\ status = "loading" + /\ IF StaleApplies + THEN /\ status' = "error" /\ live' = [c \in Consumers |-> "error"] + /\ wait' = IF wait = "pending" THEN "rejected" ELSE wait + /\ effect' = "disposed" + ELSE UNCHANGED <> + /\ UNCHANGED <> + +\* The app recovers: cleanup ends the old waits quietly, then a fresh attempt (F3). +AppRestart == + /\ attempt > 0 /\ attempt < MaxAttempts + /\ FreshAttempt(TRUE) + +\* Weakening: the framework restarts a failed attempt by itself (F9). +AutoRestart == + /\ FrameworkRetry /\ status = "error" /\ attempt < MaxAttempts + /\ FreshAttempt(FALSE) + +Next == + \/ Start \/ TransientError \/ RetrySucceeds \/ SourceReady \/ FinalError + \/ SdkGivesUp \/ LateFailure \/ RequestFails \/ StaleCallback + \/ AppRestart \/ AutoRestart + +Spec == Init /\ [][Next]_vars + +--------------------------------------------------------------------------- +TypeOK == + /\ status \in {"idle", "loading", "ready", "error"} + /\ wait \in {"none", "pending", "resolved", "rejected", "aborted"} + /\ live \in [Consumers -> {"loading", "ready", "error"}] + /\ effect \in {"active", "disposed"} + +\* F1: while the source retries, nothing reports failure. +RetryIsNotFailure == + retrying => /\ status = "loading" /\ wait # "rejected" /\ effect = "active" + /\ live["lazy"] # "error" +\* F2: a final error is final for its attempt. +NoHeal == finalFailed => status = "error" +\* F4 and F8: a final error reaches every consumer, and no wait is left pending. +PassThrough == + status = "error" => /\ wait # "pending" /\ effect = "disposed" + /\ \A c \in Consumers : live[c] = "error" +\* F5 and F6: ready means a usable snapshot is visible. +ReadyMeansRows == status = "ready" => rows +\* F10: a source that stopped trying has reported ready or a final error. +StoppedSourceReported == (status = "loading" /\ attempt > 0) => alive +\* F7: an error in the current attempt is that attempt's own final failure. +ErrorIsOwn == status = "error" => finalFailed +\* F11: consumers agree when no request-specific failure happened. +ConsumersAgree == ~reqFailed => live["root"] = live["lazy"] +\* F12: a request-specific failure leaves a healthy source healthy. +RequestFailureIsLocal == (reqFailed /\ ~finalFailed) => status # "error" +\* F3 and F9: only the app starts a new attempt. +OnlyAppRestarts == byApp +============================================================================= diff --git a/docs/contributing/designs/source-failure-and-health/SourceHealth.tla b/docs/contributing/designs/source-failure-and-health/SourceHealth.tla new file mode 100644 index 0000000000..f078ac0857 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/SourceHealth.tla @@ -0,0 +1,113 @@ +---------------------------- MODULE SourceHealth ---------------------------- +(***************************************************************************) +(* The source health channel. Status gates the first ready; health is *) +(* metadata that a source reports after it. A live query exposes an error *) +(* tree with one node per collection it depends on. The tree always has *) +(* the same shape, and each node mirrors its collection's reports. *) +(* *) +(* Topology: sources A and B. Live query L1 reads A. Live query Top reads *) +(* L1 and B, so A reaches Top only through L1. Properties H1 to H11 are *) +(* the frozen list in README.md. *) +(***************************************************************************) +EXTENDS Naturals + +CONSTANTS + HealthFlipsStatus, \* a post-ready failure changes the gate status (old Electric/PowerSync) + NestedDoesNotRelay, \* a nested live query does not pass its sources' health up + FrameworkClears, \* the framework clears an error without a source report + DropHealthyNodes, \* the tree lists only failing nodes + FailureClearsRows \* a post-ready failure discards visible rows + +Sources == {"A", "B"} +Nodes == {"A", "B", "L1"} \* the nodes in Top's error tree + +VARIABLES + status, \* gate per source: "loading" | "ready" | "error" + health, \* per source, after ready: "healthy" | "failed" + rows, \* per source: visible rows exist + tree, \* Top's error tree: node -> "ok" | "error"; DOMAIN is the shape + topStatus, \* Top's gate: "loading" | "ready" | "error" + topRows, \* Top's visible rows + reported \* per source: the source's latest report was a failure + +vars == <> + +\* What Top's tree must show, derived only from source state (H4, H11). +NodeState(n) == + CASE n = "L1" -> IF status["A"] = "error" \/ health["A"] = "failed" THEN "error" ELSE "ok" + [] OTHER -> IF status[n] = "error" \/ health[n] = "failed" THEN "error" ELSE "ok" + +Relay == + [n \in (IF DropHealthyNodes THEN {m \in Nodes : NodeState(m) = "error"} ELSE Nodes) |-> + IF NestedDoesNotRelay /\ n = "L1" THEN "ok" ELSE NodeState(n)] + +\* The gate is unchanged from the failure law: Top waits for both inputs. +TopGate == + IF \E s \in Sources : status[s] = "error" THEN "error" + ELSE IF \A s \in Sources : status[s] = "ready" THEN "ready" ELSE "loading" + +Propagate(keepGate) == + /\ tree' = Relay' + /\ topStatus' = IF keepGate /\ topStatus = "ready" THEN "ready" ELSE TopGate' + /\ topRows' = IF topStatus' = "ready" THEN rows'["A"] /\ rows'["B"] ELSE topRows + +Init == + /\ status = [s \in Sources |-> "loading"] + /\ health = [s \in Sources |-> "healthy"] + /\ rows = [s \in Sources |-> FALSE] + /\ reported = [s \in Sources |-> FALSE] + /\ tree = [n \in Nodes |-> "ok"] + /\ topStatus = "loading" /\ topRows = FALSE + +\* Before the first ready, the gate decides (H2). +Ready(s) == + /\ status[s] = "loading" + /\ status' = [status EXCEPT ![s] = "ready"] /\ rows' = [rows EXCEPT ![s] = TRUE] + /\ UNCHANGED <> + /\ Propagate(TRUE) + +InitialFailure(s) == + /\ status[s] = "loading" + /\ status' = [status EXCEPT ![s] = "error"] + /\ UNCHANGED <> + /\ Propagate(TRUE) + +\* After the first ready, a failure is metadata (H1). +ReportFailure(s) == + /\ status[s] = "ready" /\ health[s] = "healthy" + /\ health' = [health EXCEPT ![s] = "failed"] /\ reported' = [reported EXCEPT ![s] = TRUE] + /\ status' = IF HealthFlipsStatus THEN [status EXCEPT ![s] = "error"] ELSE status + /\ rows' = IF FailureClearsRows THEN [rows EXCEPT ![s] = FALSE] ELSE rows + /\ Propagate(~HealthFlipsStatus) + +\* Only the source clears its own failure, by reporting that sync works (H3). +ReportHealthy(s) == + /\ health[s] = "failed" + /\ health' = [health EXCEPT ![s] = "healthy"] /\ reported' = [reported EXCEPT ![s] = FALSE] + /\ UNCHANGED <> + /\ Propagate(TRUE) + +\* Weakening: the framework clears a failure on its own (H3). +FrameworkClear(s) == + /\ FrameworkClears /\ health[s] = "failed" + /\ health' = [health EXCEPT ![s] = "healthy"] + /\ UNCHANGED <> + /\ Propagate(TRUE) + +Next == \E s \in Sources : + Ready(s) \/ InitialFailure(s) \/ ReportFailure(s) \/ ReportHealthy(s) \/ FrameworkClear(s) + +Spec == Init /\ [][Next]_vars + +--------------------------------------------------------------------------- +\* H11: the tree always has the same shape. +StableShape == DOMAIN tree = Nodes +\* H4: every node mirrors its collection, including through the nested live query. +TreeMirrorsSources == \A n \in DOMAIN tree : tree[n] = NodeState(n) +\* H3: a failure shows until its source reports otherwise. +OnlySourceClears == \A s \in Sources : (health[s] = "healthy") => ~reported[s] +\* H1 and H5: after Top's first ready, health never moves its gate or its rows. +GateIgnoresHealth == [][topStatus = "ready" => (topStatus' = "ready" /\ topRows')]_vars +\* H2: the gate's error is explained by a node in the tree. +GateExplained == topStatus = "error" => \E n \in DOMAIN tree : tree[n] = "error" +============================================================================= diff --git a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md new file mode 100644 index 0000000000..4b3216cc8a --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md @@ -0,0 +1,248 @@ +# Draft: what users would see (candidate v2) + +This is a proposal, not shipped behavior. It applies the repairs from the +hostile assay and the fracture scan to `HEALTH-CANDIDATE.md`. + +--- + +## Guide: errors, readiness, and stale data + +A Collection answers two separate questions: + +| Question | Where to look | Changes when | +| --- | --- | --- | +| Can I show this data yet? | `status`, `isReady`, `isError` | Only during the initial load of a sync attempt | +| Is this data still being kept up to date? | `errors` (the error tree) | Any time a source reports a problem or recovers | + +### The initial load + +Until a Collection has its first usable snapshot, `status` is `loading`. If the +source keeps retrying, it stays `loading`. If the source gives up, `status` +becomes `error`, `isError` is `true`, `preload()` rejects with the original +cause, and every live query that depends on it is in error too. + +An initial error is final. TanStack DB does not retry it or recover from it on +its own. To try again, restart the query: + +```ts +await todosQuery.cleanup() +await todosQuery.preload() +``` + +### After the first ready + +Once the data has loaded, it stays. If the source later loses its connection or +a refresh fails, `status` stays `ready` and your rows stay on screen, even as +they get older. The problem appears in `errors` instead. + +> **Important:** `isError` only describes the initial load. To show a +> "data may be out of date" banner, check `errors.hasErrors`. + +```tsx +function TaskList() { + const { data, isLoading, isError, errors } = useLiveQuery((q) => + q + .from({ task: tasks }) + .join({ user: users }, ({ task, user }) => eq(task.assigneeId, user.id)), + ) + + if (isLoading) return + if (isError) return + + return ( + <> + {errors.hasErrors && } + {data.map((row) => ( + + ))} + + ) +} +``` + +### The error tree + +Every live query result includes `errors`. It always has the same shape for a +given query: one node for the live query, and one node for each source in the +query, in query order. Healthy nodes are present too, with `isError: false`. + +```ts +type ErrorTreeNode = { + alias: string // the query alias, or "root" + collectionId: string + kind: 'source' | 'live-query' | 'include' + status: CollectionStatus // the node's initial-load gate + isError: boolean // this node has a problem now + hasErrors: boolean // this node or any node below it + error: CollectionErrorInfo | null + instances?: { total: number; inError: number } // include nodes only + sources: Array +} + +type CollectionErrorInfo = { + phase: 'initial' | 'sync' | 'request' + final: boolean // the source has stopped trying; only a restart clears it + name: string + message: string + code?: string + at: number // epoch milliseconds + cause: unknown // the original value; omitted from JSON +} +``` + +For the query above, while the users sync is disconnected: + +```json +{ + "alias": "root", + "collectionId": "live-query-1", + "kind": "live-query", + "status": "ready", + "isError": false, + "hasErrors": true, + "error": null, + "sources": [ + { + "alias": "task", + "collectionId": "tasks", + "kind": "source", + "status": "ready", + "isError": false, + "hasErrors": false, + "error": null, + "sources": [] + }, + { + "alias": "user", + "collectionId": "users", + "kind": "source", + "status": "ready", + "isError": true, + "hasErrors": true, + "error": { + "phase": "sync", + "final": false, + "name": "FetchError", + "message": "Network request failed", + "at": 1760342400000 + }, + "sources": [] + } + ] +} +``` + +Phases: + +- `initial`: the source gave up before its first ready. `status` is `error`. +- `sync`: the source reported a problem after ready. `final: false` means it + is still trying; `final: true` means it stopped, and only a restart clears it. +- `request`: loading data for this query failed while its source stayed + healthy. It clears when that data loads, or when the query stops needing it. + +A collection used twice in one query, for example a self-join, appears once per +alias. An include has one node for all of its per-row child collections, with +`instances` counting how many are in error. + +`errors` is plain data apart from `cause`, so you can log or send it: + +```ts +useEffect(() => { + if (errors.hasErrors) reportToMonitoring(JSON.parse(JSON.stringify(errors))) +}, [errors]) +``` + +Server-side rendering does not transfer `errors`. The client builds its own +tree from its own sync. + +### Effects + +An Effect is disposed by an initial-load error, as today. After its first +ready, a source problem does not dispose it. Subscribe to health instead: + +```ts +createEffect({ + query: (q) => q.from({ order: orders }), + onBatch: (events) => { + /* ... */ + }, + onHealthChange: (errors) => { + if (errors.hasErrors) pauseAutomations() + else resumeAutomations() + }, +}) +``` + +--- + +## Guide for adapter authors + +Your sync function receives two new callbacks: + +```ts +sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy }) => { + const stream = connect({ + onData: (rows) => { + begin() + rows.forEach((row) => write({ type: 'upsert', value: row })) + commit() + if (!readyOnce) { + markReady() + readyOnce = true + } else { + reportHealthy() // a successful sync after a problem + } + }, + onError: (error, { willRetry }) => { + if (!readyOnce) { + // Keep trying silently. Report only when you give up. + if (!willRetry) markError(error) + } else { + // The data is already showing. Report the problem; keep the rows. + reportError(error, { final: !willRetry }) + } + }, + }) + return () => stream.close() +} +``` + +The rules: + +1. While you are still retrying the initial load, call nothing. The + Collection stays `loading`. +2. Call `markError(error)` only when you give up before the first ready. Do + not call `markReady()` after that; restart is the app's job. +3. After the first ready, call `reportError(error, { final })` for problems + and `reportHealthy()` when sync works again. Never move a ready Collection + to `error`. +4. Do not discard rows because sync failed. + +`markError(error)` after the first ready still works during migration. It is +treated as `reportError(error, { final: true })` and logs a development +warning. + +### Migration notes for the built-in adapters + +- **Electric:** a stream error that `onError` retries is not reported. When the + stream gives up after the first ready, it reports `final: true` instead of + moving the Collection to `error`. +- **PowerSync:** a failed tracking rebuild reports `reportError`; a successful + rebuild reports `reportHealthy()` instead of `markReady()`. +- **query-db:** a failed refetch after ready reports `reportError`; + `utils.lastError`, `utils.isError`, and `utils.errorCount` keep working. A + successful refetch no longer revives a Collection whose initial load failed; + call `cleanup()` and `preload()` instead. +- **TrailBase, RxDB, persistence:** post-ready failures move to + `reportError`. Persistence keeps local storage failures on `persistedError`. + +--- + +## Open questions this draft does not settle + +- Ordering: `health:change` fires in the same tick, after `status:change` + when both change. This is proposed, not checked. +- Follower tabs: how a follower learns that the leader's upstream failed. +- Pooled live queries that share sources. +- Whether `errors` should be a new object on every change or keep stable + node identities for memoization. diff --git a/docs/contributing/designs/source-failure-and-health/evidence/model-d-source-error-grammar.patch b/docs/contributing/designs/source-failure-and-health/evidence/model-d-source-error-grammar.patch new file mode 100644 index 0000000000..1385e175ad --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/evidence/model-d-source-error-grammar.patch @@ -0,0 +1,214 @@ +diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts +index ee1f8e9dd..5f4228c42 100644 +--- a/packages/db/tests/query/includes-temporal-oracle.test.ts ++++ b/packages/db/tests/query/includes-temporal-oracle.test.ts +@@ -2727,35 +2727,54 @@ function disturbedHistories( + ] + } + +-// A source error before settlement rejects the pending load and puts the +-// source Collection in `error`, in that order, as Electric does when a stream +-// error is final. `source-ready` then recovers the source. Each history moves +-// the source's readiness after the error, so recovery is the source's first +-// `ready` after it; a history may also settle before recovery, when no +-// request is pending. A `fail` rejects the pending load while the source +-// stays healthy: the hostile control for the same rejection. ++// A source error rejects any pending load and puts the source Collection in ++// `error`, in that order, as Electric does when a stream error is final. The ++// first `source-ready` after the error recovers the source; a history whose ++// source was ready before the error gets a recovery at every later position, ++// so ready → error → ready is reached as well as an error before the first ++// ready. The error can come before or after `settle`: after it, the query is ++// already ready and no load is pending. A `truncate` can arrive during the ++// error or right after its recovery. A `fail` rejects the pending load while ++// the source stays healthy, the hostile control for the same rejection, with ++// the source's readiness at every position. ++function insertStep( ++ history: ReadonlyArray, ++ at: number, ++ step: LaggingSourceStep, ++): Array { ++ return [...history.slice(0, at), step, ...history.slice(at)] ++} ++ + function sourceErrorHistories( + failure: `source-error` | `fail`, + ): Array> { + const histories = new Map>() ++ const add = (history: Array) => ++ histories.set(history.join(), history) + for (const history of laggingSourceHistories(`on-demand`)) { +- const rest = history.filter((step) => step !== `source-ready`) +- for (let at = 0; at <= rest.indexOf(`settle`); at++) { +- const failed = [...rest.slice(0, at), failure, ...rest.slice(at)] +- const recoveries = +- failure === `fail` +- ? [history.slice(0, at).includes(`source-ready`) ? 0 : failed.length] +- : Array.from( +- { length: failed.length - at }, +- (_, offset) => at + 1 + offset, ++ if (failure === `fail`) { ++ const rest = history.filter((step) => step !== `source-ready`) ++ for (let at = 0; at <= rest.indexOf(`settle`); at++) { ++ const failed = insertStep(rest, at, `fail`) ++ for (let ready = 0; ready <= failed.length; ready++) ++ add(insertStep(failed, ready, `source-ready`)) ++ } ++ continue ++ } ++ for (let at = 0; at <= history.length; at++) { ++ const errored = insertStep(history, at, `source-error`) ++ const later = errored.indexOf(`source-ready`, at + 1) ++ const recovered = ++ later >= 0 ++ ? [errored] ++ : Array.from({ length: errored.length - at }, (_, offset) => ++ insertStep(errored, at + 1 + offset, `source-ready`), + ) +- for (const ready of recoveries) { +- const candidate = [ +- ...failed.slice(0, ready), +- `source-ready` as const, +- ...failed.slice(ready), +- ] +- histories.set(candidate.join(), candidate) ++ for (const candidate of recovered) { ++ add(candidate) ++ const recovery = candidate.indexOf(`source-ready`, at + 1) ++ add(insertStep(candidate, recovery, `truncate`)) ++ add(insertStep(candidate, recovery + 1, `truncate`)) + } + } + } +@@ -2783,6 +2802,11 @@ type LaggingSourceFacts = { + // The source Collection is in `error`: its rejected loads belong to that + // error, and recovery acquires their demand again. + sourceErrored: boolean ++ // The source error rejected a pending load, so recovery acquires its ++ // demand again. ++ detachedByError: boolean ++ // Acquisitions the model expects beyond the first, one per such recovery. ++ reacquisitions: number + // A load rejected while its source was healthy: a terminal query failure. + failed: boolean + preloadFailed: boolean +@@ -2828,7 +2852,9 @@ function expectedLiveQueryObservation( + facts: LaggingSourceFacts, + ): LiveQueryObservation { + const mode = LAGGING_SOURCE_SHAPES[shape] +- const ready = initiallyReady(mode, facts) ++ const initial = initiallyReady(mode, facts) ++ // A source in `error` puts the live query in error until it recovers. ++ const ready = initial && !facts.sourceErrored + const heldIds = facts.posts + .filter((post) => postMatchesShape(shape, facts, post)) + .map(({ id }) => id) +@@ -2836,7 +2862,7 @@ function expectedLiveQueryObservation( + return { + ready, + // A preload settles at readiness, or rejects with the source error. +- preloadSettled: ready || facts.preloadFailed, ++ preloadSettled: initial || facts.preloadFailed, + preloadFailed: facts.preloadFailed, + // A failed query owes no rows; a recovering one publishes through the + // restart replay barrier. +@@ -2848,7 +2874,15 @@ function expectedLiveQueryObservation( + : facts.settled + ? heldIds.slice(-1) + : [], +- acquisitions: mode === `on-demand` ? 1 : 0, ++ // A replay after a truncate or rekey, and an ordered window, may issue ++ // extra requests by implementation choice; only acquisition is judged. ++ // Otherwise each recovery from a rejecting source error acquires once. ++ acquisitions: ++ mode !== `on-demand` ++ ? 0 ++ : shape === `ordered` || facts.disturbed ++ ? 1 ++ : 1 + facts.reacquisitions, + } + } + +@@ -3182,15 +3216,19 @@ function applyLaggingSourceStep( + facts.replayPending = true + } else if (step === `source-error` || step === `fail`) { + const error = new Error(`subset load failed`) ++ // Before settlement a load is pending; after it, none is. ++ const pending = !facts.settled + for (const source of sources) { + source.rejectPending(error) + if (step === `source-error`) source.markError(error) + } +- if (step === `source-error`) facts.sourceErrored = true +- else facts.failed = true +- facts.disturbed = true +- facts.replayPending = step === `source-error` +- facts.preloadFailed = true ++ if (step === `source-error`) { ++ facts.sourceErrored = true ++ facts.detachedByError ||= pending ++ } else facts.failed = true ++ facts.replayPending ||= step === `source-error` && pending ++ // Only a preload that has not settled can reject. ++ if (pending) facts.preloadFailed = true + } else if (step === `settle`) { + for (const source of sources) + for (const acquisition of source.acquisitions) acquisition.resolve() +@@ -3201,10 +3239,12 @@ function applyLaggingSourceStep( + facts.replayPending = false + } + } else if (facts.sourceErrored) { +- // Recovery: both sources become ready and acquire their demand again. ++ // Recovery: both sources become ready and acquire detached demand again. + for (const source of sources) source.markReady() + facts.sourceErrored = false + facts.sourceReady = true ++ if (facts.detachedByError) facts.reacquisitions += 1 ++ facts.detachedByError = false + } else { + // The twin's source has been ready since its start. + main.source.markReady() +@@ -3255,6 +3295,8 @@ function createLaggingSourceDriver( + readOwes: undefined, + replayPending: false, + sourceErrored: false, ++ detachedByError: false, ++ reacquisitions: 0, + failed: false, + preloadFailed: false, + }, +@@ -3417,6 +3459,8 @@ function laggingSourceProjection( + twinDisagreement: undefined, + }), + assertEqual: (observed, expected) => { ++ if (process.env.DEBUG_MODEL_D && JSON.stringify(observed) !== JSON.stringify(expected)) ++ require('node:fs').appendFileSync(process.env.DEBUG_MODEL_D + '.diff', JSON.stringify({ observed, expected }) + '\n') + expect(observed).toEqual(expected) + return undefined + }, +@@ -3428,11 +3472,20 @@ async function expectLaggingSourceTraceMatches( + shape: LaggingSourceShape, + steps: ReadonlyArray, + ): Promise { +- await runTrace({ +- steps, +- driver: createLaggingSourceDriver(consumer, shape), +- projection: laggingSourceProjection(consumer, shape), +- }) ++ try { ++ await runTrace({ ++ steps, ++ driver: createLaggingSourceDriver(consumer, shape), ++ projection: laggingSourceProjection(consumer, shape), ++ }) ++ } catch (error) { ++ if (process.env.DEBUG_MODEL_D) ++ require('node:fs').appendFileSync( ++ process.env.DEBUG_MODEL_D, ++ JSON.stringify({ consumer, shape, steps, error: String((error as any).message), cause: String((error as any).cause?.message ?? '') }) + '\n', ++ ) ++ throw error ++ } + } + + // Each generated lane uses one grammar and one check in both campaigns. A diff --git a/docs/contributing/designs/source-failure-and-health/evidence/pr-2129-review-probes.test.ts.txt b/docs/contributing/designs/source-failure-and-health/evidence/pr-2129-review-probes.test.ts.txt new file mode 100644 index 0000000000..8cfa419561 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/evidence/pr-2129-review-probes.test.ts.txt @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createDeferred } from '../../src/deferred.js' +import { LoadSubsetOperationAbortedError } from '../../src/errors.js' +import { + createLiveQueryCollection, + eq, +} from '../../src/query/index.js' +import { flushPromises } from '../utils.js' +import type { Deferred } from '../../src/deferred.js' + +type Post = { id: number; userId: number; title: string } +type User = { id: number; name: string } + +let seq = 0 +function makeSource(opts: { readyAtStart?: boolean } = {}) { + const acquisitions: Array> = [] + let s: any + const collection = createCollection({ + id: `scratch-src-${++seq}`, + getKey: (p) => p.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (params: any) => { + s = params + if (opts.readyAtStart) params.markReady() + return { + loadSubset: ({ signal }: { signal?: AbortSignal }) => { + const d = createDeferred() + acquisitions.push(d) + signal?.addEventListener(`abort`, () => + d.reject(new LoadSubsetOperationAbortedError()), + ) + return d.promise + }, + } + }, + }, + }) + return { + collection, + acquisitions, + pending: () => acquisitions.filter((a) => a.isPending()).length, + write: (p: Post) => { + s.begin() + s.write({ type: `insert`, value: p }) + s.commit() + }, + truncate: () => { + s.begin() + s.truncate() + s.commit() + }, + markReady: () => s.markReady(), + markError: (e: unknown) => s.markError(e), + rejectPending: (e: unknown) => { + for (const a of acquisitions) if (a.isPending()) a.reject(e) + }, + resolvePending: () => { + for (const a of acquisitions) if (a.isPending()) a.resolve() + }, + } +} + +function makeUsers() { + return createCollection({ + id: `scratch-users-${++seq}`, + getKey: (u) => u.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }: any) => { + begin() + write({ type: `insert`, value: { id: 1, name: `u1` } }) + commit() + markReady() + }, + }, + }) +} + +const direct = (src: ReturnType) => + createLiveQueryCollection((q) => + q.from({ p: src.collection }).where(({ p }) => eq(p.userId, 1)), + ) + +const joined = (src: ReturnType) => { + const users = makeUsers() + return createLiveQueryCollection((q) => + q + .from({ u: users }) + .leftJoin({ p: src.collection }, ({ u, p }) => eq(u.id, p.userId)) + .select(({ u, p }) => ({ uid: u.id, pid: p?.id })), + ) +} + +const err = new Error(`source failed`) + +async function errorThenRecover( + src: ReturnType, + between: () => void = () => {}, +) { + src.rejectPending(err) + src.markError(err) + await flushPromises() + between() + await flushPromises() +} + +describe(`scratch #2129 evaluation`, () => { + it(`baseline: direct recovers after error → ready`, async () => { + const src = makeSource() + const live = direct(src) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src) + src.markReady() + await flushPromises() + const before = live.status + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + console.log(`BASELINE direct before=${before} after=${live.status} rows=${live.size} acq=${src.acquisitions.length}`) + expect(live.status).toBe(`ready`) + }) + + it(`C1a: truncate during source error, then markReady`, async () => { + const src = makeSource() + const live = direct(src) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src, () => src.truncate()) + src.markReady() + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + src.resolvePending() + await flushPromises() + console.log(`C1a direct status=${live.status} rows=${live.size} acq=${src.acquisitions.length} pending=${src.pending()}`) + expect(live.status).toBe(`ready`) + }) + + it(`C1b: truncate right after markReady (before restart microtask)`, async () => { + const src = makeSource() + const live = direct(src) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src) + src.markReady() + src.truncate() + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + src.resolvePending() + await flushPromises() + console.log(`C1b direct status=${live.status} rows=${live.size} acq=${src.acquisitions.length} pending=${src.pending()}`) + expect(live.status).toBe(`ready`) + }) + + it(`C2: join with truncate during error`, async () => { + const src = makeSource() + const live = joined(src) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src, () => src.truncate()) + src.markReady() + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + src.resolvePending() + await flushPromises() + console.log(`C2 join status=${live.status} rows=${JSON.stringify(live.toArray.map((r: any) => r.pid))} acq=${src.acquisitions.length}`) + expect(live.status).toBe(`ready`) + }) + + it(`C3: query starts while source already in error; recovery holds load`, async () => { + const src = makeSource() + src.markError(err) + await flushPromises() + const live = direct(src) + void live.preload().catch(() => {}) + await flushPromises() + const statusDuringError = live.status + src.markReady() + await flushPromises() + const readyBeforeLoad = live.status + const pendingAtCheck = src.pending() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + console.log(`C3 duringError=${statusDuringError} afterRecoverHeld=${readyBeforeLoad} pending=${pendingAtCheck} final=${live.status} rows=${live.size}`) + expect(readyBeforeLoad).not.toBe(`ready`) + }) + + it(`C3b: query starts while source in error, markReady synchronously checked`, async () => { + const src = makeSource() + src.markError(err) + await flushPromises() + const live = direct(src) + void live.preload().catch(() => {}) + await flushPromises() + let readyAtMarkReady: string | undefined + live.on(`status:change`, ({ status }: any) => { + if (status === `ready` && readyAtMarkReady === undefined) + readyAtMarkReady = `pending=${src.pending()} acq=${src.acquisitions.length}` + }) + src.markReady() + await flushPromises() + console.log(`C3b firstReady=${readyAtMarkReady} status=${live.status}`) + expect(readyAtMarkReady).toBeUndefined() + }) + + it(`C7: second error during recovery replay, then recover`, async () => { + const src = makeSource() + const live = joined(src) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src) + src.markReady() + await flushPromises() + const acqAfterFirst = src.acquisitions.length + await errorThenRecover(src) + const midStatus = live.status + src.markReady() + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + console.log(`C7 join acqAfterFirst=${acqAfterFirst} mid=${midStatus} final=${live.status} acq=${src.acquisitions.length} rows=${JSON.stringify(live.toArray.map((r: any) => r.pid))}`) + expect(live.status).toBe(`ready`) + }) + + it(`C7 direct: second error during recovery replay, then recover`, async () => { + const src = makeSource() + const live = direct(src) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src) + src.markReady() + await flushPromises() + await errorThenRecover(src) + src.markReady() + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + console.log(`C7d final=${live.status} acq=${src.acquisitions.length} rows=${live.size}`) + expect(live.status).toBe(`ready`) + }) + + it(`ready→error→ready: error after first ready, then recover`, async () => { + const src = makeSource() + const live = joined(src) + void live.preload().catch(() => {}) + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.resolvePending() + await flushPromises() + const s1 = live.status + src.markReady() + await flushPromises() + // A later lazy demand pending when error hits: change nothing; just error + src.markError(err) + await flushPromises() + const s2 = live.status + src.markReady() + await flushPromises() + console.log(`RER join s1=${s1} s2=${s2} final=${live.status}`) + }) + + it(`C8: plain subscribeChanges + requestSnapshot loadSubsetError during source error`, async () => { + const src = makeSource() + const events: Array = [] + const sub = src.collection.subscribeChanges(() => {}, { + includeInitialState: false, + } as any) + ;(sub as any).on?.(`loadSubsetError`, () => events.push(`loadSubsetError`)) + ;(sub as any).requestSnapshot?.({ where: undefined }) + await flushPromises() + src.rejectPending(err) + src.markError(err) + await flushPromises() + console.log(`C8 events=${JSON.stringify(events)} lastError=${String((sub as any).lastError?.message)} acq=${src.acquisitions.length}`) + sub.unsubscribe() + }) + + it(`C8b: ordered window source error then recover`, async () => { + const src = makeSource() + const live = createLiveQueryCollection((q) => + q + .from({ p: src.collection }) + .orderBy(({ p }) => p.id, `desc`) + .limit(1), + ) + void live.preload().catch(() => {}) + await flushPromises() + await errorThenRecover(src) + src.markReady() + await flushPromises() + src.write({ id: 1, userId: 1, title: `a` }) + src.write({ id: 2, userId: 1, title: `b` }) + for (let i = 0; i < 5; i++) { + src.resolvePending() + await flushPromises() + } + console.log(`C8b ordered status=${live.status} rows=${JSON.stringify(live.toArray.map((r: any) => r.id))} acq=${src.acquisitions.length}`) + expect(live.status).toBe(`ready`) + expect(live.toArray.map((r: any) => r.id)).toEqual([2]) + }) + + it(`C8c: subscription load error event during source error`, async () => { + const src = makeSource() + const events: Array = [] + const sub = src.collection.subscribeChanges(() => {}, { + includeInitialState: false, + onLoadSubsetError: () => events.push(`error`), + } as any) + ;(sub as any).requestSnapshot({}) + await flushPromises() + src.rejectPending(err) + src.markError(err) + await flushPromises() + console.log(`C8c events=${JSON.stringify(events)} lastError=${String((sub as any).lastError?.message)}`) + sub.unsubscribe() + }) +}) diff --git a/docs/contributing/designs/source-failure-and-health/health-safe.cfg b/docs/contributing/designs/source-failure-and-health/health-safe.cfg new file mode 100644 index 0000000000..6b537afc45 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/health-safe.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec +CONSTANTS + HealthFlipsStatus = FALSE + NestedDoesNotRelay = FALSE + FrameworkClears = FALSE + DropHealthyNodes = FALSE + FailureClearsRows = FALSE +INVARIANT StableShape +INVARIANT TreeMirrorsSources +INVARIANT OnlySourceClears +PROPERTY GateIgnoresHealth +INVARIANT GateExplained diff --git a/docs/contributing/designs/source-failure-and-health/health-weak-clears-rows.cfg b/docs/contributing/designs/source-failure-and-health/health-weak-clears-rows.cfg new file mode 100644 index 0000000000..3f26defed5 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/health-weak-clears-rows.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + HealthFlipsStatus = FALSE + NestedDoesNotRelay = FALSE + FrameworkClears = FALSE + DropHealthyNodes = FALSE + FailureClearsRows = TRUE +PROPERTY GateIgnoresHealth diff --git a/docs/contributing/designs/source-failure-and-health/health-weak-drop-healthy.cfg b/docs/contributing/designs/source-failure-and-health/health-weak-drop-healthy.cfg new file mode 100644 index 0000000000..633d8a57d2 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/health-weak-drop-healthy.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + HealthFlipsStatus = FALSE + NestedDoesNotRelay = FALSE + FrameworkClears = FALSE + DropHealthyNodes = TRUE + FailureClearsRows = FALSE +INVARIANT StableShape diff --git a/docs/contributing/designs/source-failure-and-health/health-weak-flips-status.cfg b/docs/contributing/designs/source-failure-and-health/health-weak-flips-status.cfg new file mode 100644 index 0000000000..381cea5fbb --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/health-weak-flips-status.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + HealthFlipsStatus = TRUE + NestedDoesNotRelay = FALSE + FrameworkClears = FALSE + DropHealthyNodes = FALSE + FailureClearsRows = FALSE +PROPERTY GateIgnoresHealth diff --git a/docs/contributing/designs/source-failure-and-health/health-weak-framework-clears.cfg b/docs/contributing/designs/source-failure-and-health/health-weak-framework-clears.cfg new file mode 100644 index 0000000000..d9ebd8496c --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/health-weak-framework-clears.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + HealthFlipsStatus = FALSE + NestedDoesNotRelay = FALSE + FrameworkClears = TRUE + DropHealthyNodes = FALSE + FailureClearsRows = FALSE +INVARIANT OnlySourceClears diff --git a/docs/contributing/designs/source-failure-and-health/health-weak-no-relay.cfg b/docs/contributing/designs/source-failure-and-health/health-weak-no-relay.cfg new file mode 100644 index 0000000000..c646b3d3fe --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/health-weak-no-relay.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + HealthFlipsStatus = FALSE + NestedDoesNotRelay = TRUE + FrameworkClears = FALSE + DropHealthyNodes = FALSE + FailureClearsRows = FALSE +INVARIANT TreeMirrorsSources diff --git a/docs/contributing/designs/source-failure-and-health/safe.cfg b/docs/contributing/designs/source-failure-and-health/safe.cfg new file mode 100644 index 0000000000..6f8e544dc0 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/safe.cfg @@ -0,0 +1,22 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT RetryIsNotFailure +INVARIANT NoHeal +INVARIANT PassThrough +INVARIANT ReadyMeansRows +INVARIANT StoppedSourceReported +INVARIANT ErrorIsOwn +INVARIANT ConsumersAgree +INVARIANT RequestFailureIsLocal +INVARIANT OnlyAppRestarts diff --git a/docs/contributing/designs/source-failure-and-health/weak-clear-late.cfg b/docs/contributing/designs/source-failure-and-health/weak-clear-late.cfg new file mode 100644 index 0000000000..ec51cd6f21 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-clear-late.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = TRUE +INVARIANT TypeOK +INVARIANT ReadyMeansRows diff --git a/docs/contributing/designs/source-failure-and-health/weak-detach-waits.cfg b/docs/contributing/designs/source-failure-and-health/weak-detach-waits.cfg new file mode 100644 index 0000000000..cd5ec2b8f5 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-detach-waits.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = TRUE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT PassThrough diff --git a/docs/contributing/designs/source-failure-and-health/weak-framework-retry.cfg b/docs/contributing/designs/source-failure-and-health/weak-framework-retry.cfg new file mode 100644 index 0000000000..054f2d1780 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-framework-retry.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = TRUE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT OnlyAppRestarts diff --git a/docs/contributing/designs/source-failure-and-health/weak-heal.cfg b/docs/contributing/designs/source-failure-and-health/weak-heal.cfg new file mode 100644 index 0000000000..ac3b9417dc --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-heal.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = TRUE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT NoHeal diff --git a/docs/contributing/designs/source-failure-and-health/weak-lazy-terminal.cfg b/docs/contributing/designs/source-failure-and-health/weak-lazy-terminal.cfg new file mode 100644 index 0000000000..ad9a96dbeb --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-lazy-terminal.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = TRUE + RejectOnRetry = FALSE + LazyTerminal = TRUE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT ConsumersAgree diff --git a/docs/contributing/designs/source-failure-and-health/weak-poison.cfg b/docs/contributing/designs/source-failure-and-health/weak-poison.cfg new file mode 100644 index 0000000000..b9bee775cb --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-poison.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = TRUE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT RequestFailureIsLocal diff --git a/docs/contributing/designs/source-failure-and-health/weak-reject-on-retry.cfg b/docs/contributing/designs/source-failure-and-health/weak-reject-on-retry.cfg new file mode 100644 index 0000000000..a3e8589563 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-reject-on-retry.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = TRUE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT RetryIsNotFailure diff --git a/docs/contributing/designs/source-failure-and-health/weak-silent-give-up.cfg b/docs/contributing/designs/source-failure-and-health/weak-silent-give-up.cfg new file mode 100644 index 0000000000..19b54e4f80 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-silent-give-up.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = TRUE + StaleApplies = FALSE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT StoppedSourceReported diff --git a/docs/contributing/designs/source-failure-and-health/weak-stale.cfg b/docs/contributing/designs/source-failure-and-health/weak-stale.cfg new file mode 100644 index 0000000000..bd72d81eee --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/weak-stale.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec +CONSTANTS + MaxAttempts = 3 + HealOnReady = FALSE + RejectOnRetry = FALSE + LazyTerminal = FALSE + DetachWaits = FALSE + SilentGiveUp = FALSE + StaleApplies = TRUE + PoisonSource = FALSE + FrameworkRetry = FALSE + ClearOnLateFail = FALSE +INVARIANT TypeOK +INVARIANT ErrorIsOwn From 8f6afbd0bb72f9c9244fd5c71d2d7e12fd0aef90 Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 14:15:23 -0600 Subject: [PATCH 002/106] docs: plan TrailBase stream-end reporting without reconnect Co-authored-by: Isaac --- .../designs/source-failure-and-health/PLAN.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 81b7bffe44..808395ac9c 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -139,6 +139,16 @@ names and shapes. - [ ] **TrailBase** (`trailbase-db-collection/src/trailbase.ts`, 4 sites): post-ready stream failures call `reportError`. Check them against the #2126 fix. + - [ ] A stream that ends, whether it closes or errors, reports + `reportError(error, { final: true })` after the first ready. The + adapter does not resubscribe. The `trailbase` SDK (0.10.0) has no + reconnect or resume: `subscribe` is one `fetch` stream, and + `subscribeWs` closes without retrying. A correct reconnect would + have to subscribe again and re-list every active subset. The + maintainer decided to leave reconnect to the TrailBase maintainers. + - [ ] Update the stream-end lane in `tests/lifecycle-oracle.property.test.ts` + (added in #2126) from "rows stay, later changes are not delivered" + to also expect a final health error. - [ ] **RxDB** (`rxdb-db-collection/src/rxdb.ts`, 3 sites): post-ready failures call `reportError`. Observe what happens to pending loads on a final error (evidence gap V10). From c0985d0ebb41480bca6914d935295ecfe7f545c3 Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 16:34:10 -0600 Subject: [PATCH 003/106] feat(db)!: make an initial sync error final and add Collection health An initial sync error is now final for its sync attempt. A Collection can no longer move from `error` to `ready`; markReady() after markError() in one attempt throws, and recovery is cleanup() followed by preload(). After the first ready, a source failure is health metadata. markError() after ready is treated as reportError(error, { final: true }), and the new sync callbacks reportError() and reportHealthy() report and clear a non-final problem. Status and rows stay. Collection.health and the new health:change event expose it, and Collection.errors returns an error tree with one node per query alias, nested through live queries. A live query whose source fails before the query's first ready enters error for every shape. After its first ready, a source failure shows in the error tree and the query keeps its rows, and a failed lazy request is a request error that clears when the demand settles or is released. Query failures and contract violations, such as a source cleaned up under a live query, still crash the query. IndexedDB connection closure still retires its Collection, because closure ends admission authority. Tests that pinned same-attempt healing or a ready-to-error source failure now pin the new law. Co-authored-by: Isaac --- docs/errors.md | 10 ++ packages/db/src/collection/error-tree.ts | 37 +++++ packages/db/src/collection/events.ts | 18 +++ packages/db/src/collection/index.ts | 28 ++++ packages/db/src/collection/indexes.ts | 2 +- packages/db/src/collection/lifecycle.ts | 127 +++++++++++++++++- packages/db/src/collection/sync.ts | 9 +- packages/db/src/indexed-db.ts | 4 +- .../query/live/collection-config-builder.ts | 77 ++++++++--- .../db/src/query/live/collection-registry.ts | 2 + packages/db/src/types.ts | 72 +++++++++- .../collection-cleanup-restart-oracle.test.ts | 17 ++- packages/db/tests/collection-errors.test.ts | 5 +- .../db/tests/collection-lifecycle.test.ts | 47 +++---- ...tion-subscription-lifecycle-oracle.test.ts | 70 ++++++---- .../fixtures/development-only-messages.json | 8 ++ .../tests/fixtures/error-site-messages.json | 9 ++ .../cross-tab-boundary-oracle.test.ts | 11 +- packages/db/tests/indexed-db/recorder.ts | 8 +- .../tests/indexed-db/transport-oracle.test.ts | 11 +- .../query/includes-temporal-oracle.test.ts | 5 +- .../tests/query/live-query-collection.test.ts | 5 +- .../tests/query/subset-error-matrix.test.ts | 10 +- .../tests/truncate-readiness-oracle.test.ts | 58 ++++++-- 24 files changed, 538 insertions(+), 112 deletions(-) create mode 100644 packages/db/src/collection/error-tree.ts diff --git a/docs/errors.md b/docs/errors.md index f33e22aa84..f78e77b005 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2449,3 +2449,13 @@ Development builds show this message, with `${...}` replaced by values: ```text LocalStorage sync belongs to its options Collection. ``` + + + +## Error 243: `collection/lifecycle.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +${operation}() reports a problem after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up. +``` diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts new file mode 100644 index 0000000000..f0118afecd --- /dev/null +++ b/packages/db/src/collection/error-tree.ts @@ -0,0 +1,37 @@ +import type { CollectionErrorInfo, ErrorTreeNode } from '../types' +import type { CollectionImpl } from './index.js' + +/** A Collection that another Collection reads, under its query alias. */ +export interface ErrorTreeSource { + readonly alias: string + readonly collection: CollectionImpl +} + +/** + * Build the error tree for a Collection. A base Collection is one node. A live + * query is a node with one child per source alias, in query order, and each + * child that is itself a live query recurses. The tree is read from current + * state, so every node mirrors its Collection at the moment it is read. + */ +export function buildErrorTree( + collection: CollectionImpl, + alias: string = `root`, +): ErrorTreeNode { + const sources = collection._errorTreeSources?.() ?? [] + const lifecycle = collection._lifecycle + const error: CollectionErrorInfo | null = + lifecycle.gateProblem ?? lifecycle.health.error + const children = sources.map((source) => + buildErrorTree(source.collection, source.alias), + ) + return { + alias, + collectionId: collection.id, + kind: collection._errorTreeSources ? `live-query` : `source`, + status: collection.status, + isError: error !== null, + hasErrors: error !== null || children.some((child) => child.hasErrors), + error, + sources: children, + } +} diff --git a/packages/db/src/collection/events.ts b/packages/db/src/collection/events.ts index 8b392cd989..a7f16a2332 100644 --- a/packages/db/src/collection/events.ts +++ b/packages/db/src/collection/events.ts @@ -44,6 +44,16 @@ export interface CollectionLoadingSubsetChangeEvent { loadingSubsetTransition: `start` | `end` } +/** + * Event emitted when the Collection's error tree may have changed: its own + * health, or for a live query, the status or health of a Collection it + * depends on. Read `collection.errors` for the current tree. + */ +export interface CollectionHealthChangeEvent { + type: `health:change` + collection: Collection +} + /** * Event emitted when the collection is truncated (all data cleared) */ @@ -101,6 +111,7 @@ export type AllCollectionEvents = { 'status:change': CollectionStatusChangeEvent 'subscribers:change': CollectionSubscribersChangeEvent 'loadingSubset:change': CollectionLoadingSubsetChangeEvent + 'health:change': CollectionHealthChangeEvent truncate: CollectionTruncateEvent 'index:added': CollectionIndexAddedEvent 'index:removed': CollectionIndexRemovedEvent @@ -165,6 +176,13 @@ export class CollectionEventsManager extends EventEmitter { ) } + emitHealthChange() { + this.emit(`health:change`, { + type: `health:change`, + collection: this.collection, + }) + } + emitSubscribersChange( subscriberCount: number, previousSubscriberCount: number, diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 5c8eaa8ebf..b102ecf0a5 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -17,6 +17,8 @@ import { CollectionSyncManager } from './sync' import { CollectionIndexesManager } from './indexes' import { CollectionMutationsManager } from './mutations' import { CollectionEventsManager } from './events.js' +import { buildErrorTree } from './error-tree.js' +import type { ErrorTreeSource } from './error-tree.js' import type { SortedMap } from '../SortedMap.js' import type { PublicationDeferral } from './changes' import type { CollectionSubscription } from './subscription' @@ -30,8 +32,10 @@ import type { IndexOptions } from '../indexes/index-options.js' import type { ChangeMessage, CollectionConfig, + CollectionHealth, CollectionStatus, CurrentStateAsChangesOptions, + ErrorTreeNode, InferSchemaInput, InferSchemaOutput, InsertConfig, @@ -554,6 +558,30 @@ export class CollectionImpl< return this._lifecycle.status } + /** + * The Collection's health after its first ready. `status` only gates the + * initial load; a later sync problem appears here and the rows stay. + */ + public get health(): CollectionHealth { + return this._lifecycle.health + } + + /** + * The error tree for this Collection and every Collection it depends on. + * It always has the same shape for a given query, so apps can parse it. + */ + public get errors(): ErrorTreeNode { + return buildErrorTree(this) + } + + /** @internal A Collection this one depends on changed its error tree. */ + public _emitHealthChange(): void { + this._events.emitHealthChange() + } + + /** @internal Set by a live query: the sources its error tree lists. */ + public _errorTreeSources: (() => ReadonlyArray) | undefined + /** * Get the number of subscribers that ask for this collection's data. A live * query that has no subscriber or preload of its own does not count. diff --git a/packages/db/src/collection/indexes.ts b/packages/db/src/collection/indexes.ts index 01c1d6a878..49a0036e90 100644 --- a/packages/db/src/collection/indexes.ts +++ b/packages/db/src/collection/indexes.ts @@ -406,7 +406,7 @@ export class CollectionIndexesManager< } catch (error) { // Callers have written rows that are not yet published. Crash the // collection instead of leaving it usable with unpublished rows. - this.lifecycle.markError(error) + this.lifecycle.crash(error) throw error } } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3c3a6730bc..3cf80977f5 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -13,7 +13,12 @@ import { codedMessage, devBuild } from '../error-message' import { CleanupQueue } from './cleanup-queue' import type { IdleCallbackDeadline } from '../utils/browser-polyfills' import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { CollectionConfig, CollectionStatus } from '../types' +import type { + CollectionConfig, + CollectionErrorInfo, + CollectionHealth, + CollectionStatus, +} from '../types' import type { CollectionEventsManager } from './events' import type { CollectionIndexesManager } from './indexes' import type { CollectionChangesManager } from './changes' @@ -52,6 +57,11 @@ export class CollectionLifecycleManager< public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null private syncError: unknown + // Health is metadata after the first ready. A source's own report and a + // live query's request failures are separate, so settling a request cannot + // clear a source problem. + private sourceProblem: CollectionErrorInfo | null = null + private requestProblem: CollectionErrorInfo | null = null private cleanupConfig: () => void private statusRevision = 0 private cleaningUp = false @@ -102,8 +112,11 @@ export class CollectionLifecycleManager< > = { idle: [`loading`, `error`, `cleaned-up`], loading: [`ready`, `error`, `cleaned-up`], + // A ready Collection moves to `error` only through crash(); a source's + // later failure is health metadata. ready: [`cleaned-up`, `error`], - error: [`ready`, `cleaned-up`, `idle`], + // An initial error is final for its sync attempt. + error: [`cleaned-up`, `idle`], 'cleaned-up': [`loading`, `error`], } @@ -196,9 +209,8 @@ export class CollectionLifecycleManager< beforeEffects?: () => void, ): { error: unknown } | undefined { this.validateStatusTransition(this.status, `ready`) - // A successful initial sync or recovery establishes a ready snapshot. - if (this.status === `loading` || this.status === `error`) { - this.syncError = undefined + // A successful initial sync establishes a ready snapshot. + if (this.status === `loading`) { const readyRevision = this.statusRevision + 1 this.setStatus(`ready`, true, beforeEffects) @@ -235,13 +247,88 @@ export class CollectionLifecycleManager< return undefined } - /** Mark an asynchronous sync failure after sync has started. */ + /** + * The source gave up. Before the first ready this fails the initial sync + * of the attempt; after it, the problem is final health metadata and the + * data stays. + */ public markError(error?: unknown): void { + if (this.status === `ready`) { + if (devBuild() && process.env.NODE_ENV !== `production`) { + console.warn( + `[TanStack DB] Collection "${this.id}" called markError() after its first ready. ` + + `Call reportError(error, { final: true }) instead; the status stays ready.`, + ) + } + this.reportError(error, { final: true }) + return + } + this.crash(error) + } + + /** + * Move the Collection to `error` from any active status. Internal + * invariant failures use this even after ready, so a Collection that holds + * unpublished rows stops being usable. + */ + public crash(error?: unknown): void { this.validateStatusTransition(this.status, `error`) this.syncError = error this.setStatus(`error`) } + public get health(): CollectionHealth { + const error = this.sourceProblem ?? this.requestProblem + return { isError: error !== null, error } + } + + /** The cause of the initial gate error, as error tree detail. */ + public get gateProblem(): CollectionErrorInfo | null { + return this.status === `error` + ? describeProblem(this.syncError, `initial`, true) + : null + } + + /** A source's report after its first ready. Status and rows do not change. */ + public reportError(error: unknown, options?: { final?: boolean }): void { + this.assertReportable(`reportError`) + // A final problem stays until a restart. + if (this.sourceProblem?.final) return + this.sourceProblem = describeProblem(error, `sync`, options?.final === true) + this.events.emitHealthChange() + } + + /** The source synced successfully again. A final problem stays. */ + public reportHealthy(): void { + this.assertReportable(`reportHealthy`) + if (!this.sourceProblem || this.sourceProblem.final) return + this.sourceProblem = null + this.events.emitHealthChange() + } + + /** @internal A live query's load failed after its first ready. */ + public reportRequestError(error: unknown): void { + this.requestProblem = describeProblem(error, `request`, false) + this.events.emitHealthChange() + } + + /** @internal The failed request settled or was released. */ + public clearRequestError(): void { + if (!this.requestProblem) return + this.requestProblem = null + this.events.emitHealthChange() + } + + private assertReportable(operation: string): void { + if (this.status !== `ready`) { + throw new CollectionStateError( + devBuild() && process.env.NODE_ENV !== `production` + ? `${operation}() reports a problem after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up.` + : codedMessage(243, { operation, id: this.id, status: this.status }), + ) + } + } + /** Return the cause supplied by the current sync run, if any. */ public getSyncError(): unknown { return this.syncError @@ -480,6 +567,9 @@ export class CollectionLifecycleManager< this.hasBeenReady = false this.syncError = undefined + // Health belongs to the discarded attempt. + this.sourceProblem = null + this.requestProblem = null // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. this.onFirstReadyCallbacks = [] @@ -521,3 +611,28 @@ export class CollectionLifecycleManager< return this.beginCleanup() } } + +function describeProblem( + error: unknown, + phase: CollectionErrorInfo[`phase`], + final: boolean, +): CollectionErrorInfo { + const info = { + phase, + final, + name: error instanceof Error ? error.name : `Error`, + message: + error instanceof Error + ? error.message + : error === undefined + ? `Collection is in an error state` + : String(error), + ...(typeof (error as { code?: unknown } | null)?.code === `string` + ? { code: (error as { code: string }).code } + : {}), + at: Date.now(), + } as CollectionErrorInfo + // The original value stays available but out of JSON. + Object.defineProperty(info, `cause`, { value: error, enumerable: false }) + return info +} diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 9efc9aa7b6..ee6dcc2632 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -323,6 +323,12 @@ export class CollectionSyncManager< markError: (error?: unknown) => { if (isCurrentSync()) this.lifecycle.markError(error) }, + reportError: (error: unknown, options?: { final?: boolean }) => { + if (isCurrentSync()) this.lifecycle.reportError(error, options) + }, + reportHealthy: () => { + if (isCurrentSync()) this.lifecycle.reportHealthy() + }, truncate: (options) => { if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() @@ -389,7 +395,8 @@ export class CollectionSyncManager< this.syncEntryActive = false this.completePendingSyncEntryCleanup() finishReadyFailures() - if (isCurrentSync()) this.lifecycle.markError(error) + // A throwing sync function is a programming error, even after ready. + if (isCurrentSync()) this.lifecycle.crash(error) throw error } if (readyFailure) throw readyFailure.error diff --git a/packages/db/src/indexed-db.ts b/packages/db/src/indexed-db.ts index bad7e37ddf..5e5538f52f 100644 --- a/packages/db/src/indexed-db.ts +++ b/packages/db/src/indexed-db.ts @@ -680,7 +680,9 @@ export function indexedDBCollectionOptions( channel?.close() if (activeSync === params) { broadcastChannel = undefined - markError(connection?.error) + // Closure ends read and admission authority, even after ready, so the + // Collection retires instead of reporting health. + params.collection._lifecycle.crash(connection?.error) } } if (connection?.error) { diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index d683d262ac..4a4c81d9c7 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -34,6 +34,7 @@ import type { CollectionSubscription } from '../../collection/subscription.js' import type { RootStreamBuilder } from '@tanstack/db-ivm' import type { OrderByOptimizationInfo } from '../compiler/order-by.js' import type { Collection } from '../../collection/index.js' +import type { ErrorTreeSource } from '../../collection/error-tree.js' import type { CollectionConfigSingleRowOption, KeyedStream, @@ -114,7 +115,8 @@ export class CollectionConfigBuilder< // Error state tracking private isInErrorState = false private fatalQueryError = false - private readonly erroredSourceIds = new Set() + // Lazy demand that failed after the first ready, as request errors. + private readonly failedDemands = new Set() private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions @@ -423,10 +425,19 @@ export class CollectionConfigBuilder< return generation } + /** The source aliases of this query, in query order, for its error tree. */ + errorTreeSources(): ReadonlyArray { + return this.collectionSources.map(({ alias, collection }) => ({ + alias, + collection, + })) + } + settleDemand(planId: string, generation: number): void { const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation || demand.settled) return demand.settled = true + this.clearFailedDemand(planId) try { this.maybeRunGraphFn?.() demand.participant?.resolve() @@ -450,6 +461,14 @@ export class CollectionConfigBuilder< if (!demand || demand.generation !== generation) return const normalized = this.recordSubsetError(error) demand.participant?.reject(normalized) + // After the first ready, a failed load is a request error on this query's + // node. It clears when the demand settles later or is released. + const lifecycle = this.liveQueryCollection?._lifecycle + if (lifecycle?.status === `ready`) { + this.failedDemands.add(planId) + lifecycle.reportRequestError(normalized) + return + } this.transitionToError( devBuild() && process.env.NODE_ENV !== `production` ? `Subset demand '${planId}' failed: ${normalized.message}` @@ -548,9 +567,16 @@ export class CollectionConfigBuilder< retireDemand(planId: string): void { const demand = this.activeDemands.get(planId) this.activeDemands.delete(planId) + this.clearFailedDemand(planId) demand?.participant?.resolve() } + private clearFailedDemand(planId: string): void { + if (!this.failedDemands.delete(planId)) return + if (this.failedDemands.size === 0) + this.liveQueryCollection?._lifecycle.clearRequestError() + } + hasPendingSourceRecovery(): boolean { return Object.values(this.subscriptions).some( (subscription) => subscription.hasPendingTruncateReplacement, @@ -726,7 +752,7 @@ export class CollectionConfigBuilder< // Reset error state from any previous sync run so a restarted sync can become ready again. this.isInErrorState = false this.fatalQueryError = false - this.erroredSourceIds.clear() + this.failedDemands.clear() this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config @@ -809,7 +835,7 @@ export class CollectionConfigBuilder< this.settledWindow = this.initialWindow this.isInErrorState = false this.fatalQueryError = false - this.erroredSourceIds.clear() + this.failedDemands.clear() // Reset caches so a fresh graph/pipeline is compiled on next start // This avoids reusing a finalized D2 graph across GC restarts @@ -1109,14 +1135,20 @@ export class CollectionConfigBuilder< ) { const { status } = event - // Handle error state - any source collection in error puts live query in error + // Every source status appears in this query's error tree. + this.emitErrorTreeChange() + + // A source's initial error is final for its attempt. Before this query's + // first ready it is this query's gate error, for every shape. After it, + // the source node shows the error and this query keeps its rows. if (status === `error`) { - this.erroredSourceIds.add(sourceId) - this.setErrorState( - devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' entered error state` - : codedMessage(172, { collectionId }), - ) + if (this.liveQueryCollection?.status !== `ready`) { + this.transitionToError( + devBuild() && process.env.NODE_ENV !== `production` + ? `Source collection '${collectionId}' entered error state` + : codedMessage(172, { collectionId }), + ) + } return } @@ -1128,15 +1160,6 @@ export class CollectionConfigBuilder< } if (status === `ready`) { - const recovered = this.erroredSourceIds.delete(sourceId) - if ( - recovered && - !this.fatalQueryError && - this.erroredSourceIds.size === 0 - ) { - this.isInErrorState = false - this.maybeRunGraphFn?.() - } if ( Object.values(this.optimizableOrderByCollections).some( (info) => info.joinedFilterSourceId === sourceId, @@ -1209,8 +1232,16 @@ export class CollectionConfigBuilder< : message, ) - // Transition live query collection to error state - this.liveQueryCollection?._lifecycle.markError(error ?? new Error(message)) + // These are query failures or contract violations, such as a source + // cleaned up under this query, so they end the query even after ready. + // A source's own failure after this query's first ready, and a failed + // request, are health metadata and never reach here. + this.liveQueryCollection?._lifecycle.crash(error ?? new Error(message)) + } + + /** The error tree changed below this query; dependents read it again. */ + private emitErrorTreeChange(): void { + this.liveQueryCollection?._emitHealthChange() } private allRequiredSourcesReady() { @@ -1258,6 +1289,10 @@ export class CollectionConfigBuilder< const statusUnsubscribe = collection.on(`status:change`, (event) => { this.handleSourceStatusChange(config, sourceId, collectionId, event) }) + // Relay health changes so this query's error tree stays current. + syncState.unsubscribeCallbacks.add( + collection.on(`health:change`, () => this.emitErrorTreeChange()), + ) let cleanupStartError: Error | undefined const cleanupStartUnsubscribe = collection._onCleanupStart(() => { cleanupStartError = this.handleSourceCleanupStart(collectionId) diff --git a/packages/db/src/query/live/collection-registry.ts b/packages/db/src/query/live/collection-registry.ts index d17b182f92..c61b8e89de 100644 --- a/packages/db/src/query/live/collection-registry.ts +++ b/packages/db/src/query/live/collection-registry.ts @@ -32,6 +32,8 @@ export function registerCollectionBuilder( builder: CollectionConfigBuilder, ): void { collectionBuilderRegistry.set(collection, builder) + // The error tree's shape comes from the query, so it is fixed from creation. + collection._errorTreeSources = () => builder.errorTreeSources() } /** diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 39bd8aeb33..4d0a27ab3f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -448,13 +448,25 @@ export interface SyncConfig< * named `AbortError`. Aborting after acceptance has no effect. */ commit: (signal?: AbortSignal) => SyncAppliedReceipt - /** Signal that a usable initial or recovered snapshot is available. */ + /** Signal that a usable initial snapshot is available for this attempt. */ markReady: () => void /** - * Signal that initial sync failed before producing a usable snapshot. - * When supplied, `error` is preserved as the rejection reason from `preload()`. + * Signal that the initial sync of this attempt failed for good, before it + * produced a usable snapshot. Call it only when the source has stopped + * trying; while it retries, call nothing. When supplied, `error` is + * preserved as the rejection reason from `preload()`. Calling `markReady()` + * after `markError()` in the same attempt throws. After the first ready, + * `markError(error)` is treated as `reportError(error, { final: true })`. */ markError: (error?: unknown) => void + /** + * Report a problem after the first ready. The status and rows do not + * change. `final: true` means the source stopped trying, and only a + * restart clears the problem. Throws before the first ready. + */ + reportError: (error: unknown, options?: { final?: boolean }) => void + /** Report that sync works again after a non-final `reportError`. */ + reportHealthy: () => void /** * Replace the synced rows. By default the replacement also marks the * Collection ready. Set markReady:false to publish without changing status, @@ -720,8 +732,9 @@ export type DeleteMutationFn< * @example * // Status transitions * // idle → loading → ready (when markReady() is called) - * // Any active status can transition to → error or cleaned-up - * // error → ready after a successful sync recovery + * // loading → error when the initial sync of an attempt fails for good + * // An initial error is final for its sync attempt. cleanup() and a new + * // preload() or subscriber start a fresh attempt. */ export type CollectionStatus = /** Collection is created but sync hasn't started yet (when startSync config is false) */ @@ -737,6 +750,55 @@ export type CollectionStatus = export type SyncMode = `eager` | `on-demand` +/** + * Detail about a Collection problem. Every field except `cause` is plain data, + * so the value can be logged or sent as JSON; `cause` keeps the original value + * and is omitted from JSON. + */ +export interface CollectionErrorInfo { + /** + * `initial`: the source gave up before its first ready, so `status` is + * `error`. `sync`: the source reported a problem after its first ready. + * `request`: loading data for a live query failed while its sources stayed + * healthy. + */ + readonly phase: `initial` | `sync` | `request` + /** The source has stopped trying; only a restart clears this problem. */ + readonly final: boolean + readonly name: string + readonly message: string + readonly code?: string + /** Epoch milliseconds when the problem was reported. */ + readonly at: number + readonly cause: unknown +} + +/** A Collection's health after its first ready. Status still gates the initial load. */ +export interface CollectionHealth { + readonly isError: boolean + readonly error: CollectionErrorInfo | null +} + +/** + * One node of a live query's error tree. A live query has one node for itself + * and one for each source alias in query order; the shape is fixed by the + * query, and healthy nodes are present with `isError: false`. + */ +export interface ErrorTreeNode { + /** The query alias, or `root` for the Collection the tree was read from. */ + readonly alias: string + readonly collectionId: string + readonly kind: `source` | `live-query` + /** The node's initial-load gate. */ + readonly status: CollectionStatus + /** This node has a problem now: a gate error, a health error, or a request error. */ + readonly isError: boolean + /** This node or any node below it has a problem. */ + readonly hasErrors: boolean + readonly error: CollectionErrorInfo | null + readonly sources: ReadonlyArray +} + export interface BaseCollectionConfig< T extends object = Record, TKey extends string | number = string | number, diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index 356f91cbf0..c3dd444c3e 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -1033,7 +1033,7 @@ describe(`Collection cleanup admission oracle`, () => { }) it.each([`eager`, `on-demand`] as const)( - `recovers an errored %s source and dependent live query through their own restarts`, + `restarts a %s source that failed after ready, and its live query, through their own restarts`, async (syncMode) => { let sourceSync!: Parameters[`sync`]>[0] let starts = 0 @@ -1075,13 +1075,24 @@ describe(`Collection cleanup admission oracle`, () => { await live.preload() expect(live.get(1)?.rank).toBe(1) + // After the first ready a source failure is final health: the + // status and rows stay, and the live query's error tree reports it. sourceSync.markError(new Error(`source sync failed`)) - expect(source.status).toBe(`error`) - expect(live.status).toBe(`error`) + expect(source.status).toBe(`ready`) + expect(source.health.error).toMatchObject({ + phase: `sync`, + final: true, + }) + expect(live.status).toBe(`ready`) + expect(live.get(1)?.rank).toBe(1) + expect(live.errors.sources[0]?.isError).toBe(true) + // Only a restart clears a final problem. await live.cleanup() await source.cleanup() await live.preload() + expect(source.health.isError).toBe(false) + expect(live.errors.hasErrors).toBe(false) expect(starts).toBe(2) expect(source.status).toBe(`ready`) diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index 2efca4d202..1217ce5b71 100644 --- a/packages/db/tests/collection-errors.test.ts +++ b/packages/db/tests/collection-errors.test.ts @@ -615,7 +615,8 @@ describe(`Collection Error Handling`, () => { collectionImpl._lifecycle.validateStatusTransition(`ready`, `error`), ).not.toThrow() - // Valid transitions from error (allow recovery) + // Valid transitions from error. An initial error is final for its sync + // attempt, so the only way back to ready is a restart. expect(() => collectionImpl._lifecycle.validateStatusTransition( `error`, @@ -627,7 +628,7 @@ describe(`Collection Error Handling`, () => { ).not.toThrow() expect(() => collectionImpl._lifecycle.validateStatusTransition(`error`, `ready`), - ).not.toThrow() + ).toThrow(InvalidCollectionStatusTransitionError) // Valid transitions from cleaned-up (allow restart) expect(() => diff --git a/packages/db/tests/collection-lifecycle.test.ts b/packages/db/tests/collection-lifecycle.test.ts index f2cff754b4..46f983b9ff 100644 --- a/packages/db/tests/collection-lifecycle.test.ts +++ b/packages/db/tests/collection-lifecycle.test.ts @@ -724,11 +724,12 @@ describe(`Collection Lifecycle Management`, () => { expectedFirstReadyCalls: 1, invalid: false, }, + // An initial error is final for its sync attempt. { from: `error`, - expectedStatus: `ready`, - expectedFirstReadyCalls: 1, - invalid: false, + expectedStatus: `error`, + expectedFirstReadyCalls: 0, + invalid: true, }, { from: `idle`, @@ -745,7 +746,7 @@ describe(`Collection Lifecycle Management`, () => { ] as const)( `defines the $from -> ready transition`, async ({ from, expectedStatus, expectedFirstReadyCalls, invalid }) => { - const syncFailure = new Error(`sync failed before recovery`) + const syncFailure = new Error(`initial sync failed`) let firstReadyCalls = 0 let recoveryFirstReadyCalls = 0 const collection = createCollection<{ id: string; name: string }>({ @@ -758,11 +759,12 @@ describe(`Collection Lifecycle Management`, () => { firstReadyCalls++ }) - if (from === `ready` || from === `error`) { + if (from === `ready`) { collection._lifecycle.setStatus(`loading`) collection._lifecycle.markReady() } if (from === `error`) { + collection._lifecycle.setStatus(`loading`) collection._lifecycle.markError(syncFailure) expect(collection._lifecycle.getSyncError()).toBe(syncFailure) } else if (from === `cleaned-up`) { @@ -774,7 +776,7 @@ describe(`Collection Lifecycle Management`, () => { collection.onFirstReady(() => { recoveryFirstReadyCalls++ }) - expect(recoveryFirstReadyCalls).toBe(1) + expect(recoveryFirstReadyCalls).toBe(0) } const transitionTrace: Array< @@ -828,25 +830,11 @@ describe(`Collection Lifecycle Management`, () => { } expect(collection.status).toBe(expectedStatus) expect(firstReadyCalls).toBe(expectedFirstReadyCalls) - expect(recoveryFirstReadyCalls).toBe(from === `error` ? 1 : 0) - expect(transitionTrace).toEqual( - from === `error` - ? [ - { - kind: `status`, - previousStatus: `error`, - status: `ready`, - syncError: undefined, - }, - { - kind: `dependent-ready`, - status: `ready`, - syncError: undefined, - }, - ] - : [], + expect(recoveryFirstReadyCalls).toBe(0) + expect(transitionTrace).toEqual([]) + expect(collection._lifecycle.getSyncError()).toBe( + from === `error` ? syncFailure : undefined, ) - expect(collection._lifecycle.getSyncError()).toBeUndefined() await collection.cleanup() }, @@ -899,8 +887,10 @@ describe(`Collection Lifecycle Management`, () => { ) const firstReady = vi.fn() collection.onFirstReady(firstReady) + // An internal failure during the ready transition still ends it. A + // source's markError at this point would be health metadata instead. collection.on(`status:ready`, () => { - collection._lifecycle.markError(failure) + collection._lifecycle.crash(failure) }) collection._lifecycle.setStatus(`loading`) @@ -1187,7 +1177,7 @@ describe(`Collection Lifecycle Management`, () => { } }) - it(`ends the synchronous sync-entry boundary after an adapter failure`, async () => { + it(`rejects markReady after a failed synchronous sync entry`, async () => { const adapterFailure = new Error(`adapter entry failed`) let markReadyCallback: (() => void) | undefined const collection = createCollection<{ id: string; name: string }>({ @@ -1217,9 +1207,10 @@ describe(`Collection Lifecycle Management`, () => { thrown = error } + // The failed entry ended this sync attempt; it cannot become ready. expect(didThrow).toBe(true) - expect(thrown).toBeUndefined() - expect(collection.status).toBe(`ready`) + expect(thrown).toBeInstanceOf(InvalidCollectionStatusTransitionError) + expect(collection.status).toBe(`error`) } finally { await collection.cleanup() } diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 5585f16bc2..b719660130 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -2,6 +2,7 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { afterAll, describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' +import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { Func, PropRef, Value } from '../src/query/ir.js' import { createOnDemandCollection, flushPromises } from './utils.js' @@ -134,9 +135,12 @@ const acquisitionCellDefinitions = { 'unavailable:request': { kind: `covered` }, 'unavailable:resume': { kind: `excluded`, - reason: `recovery in the same sync run uses markReady rather than defer resume`, + reason: `an initial error is final for its sync attempt; only a restart recovers`, + }, + 'unavailable:markReady': { + kind: `excluded`, + reason: `markReady after markError in one sync attempt is a contract violation and throws`, }, - 'unavailable:markReady': { kind: `covered` }, 'unavailable:markError': { kind: `excluded`, reason: `a repeated error leaves acquisition unavailable`, @@ -2964,8 +2968,8 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { ) acquisitionCase( - [`starting:markError`, `unavailable:markReady`], - `retains demand requested during initial error for recovery in the same sync run`, + [`starting:markError`], + `keeps demand requested during initial error unacquired until a restart`, async (reach) => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const loads: Array = [] @@ -2977,7 +2981,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { startSync: false, sync: { sync: ({ markError, markReady }) => { - if (syncRunCount++ === 0) { + if (syncRunCount++ !== 1) { markReady() return { loadSubset: (options) => { @@ -3020,25 +3024,31 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect.soft(loads).toEqual([]) expect.soft(observed).toEqual([expect.any(Promise)]) - recover() - reach(`unavailable:markReady`) + // The initial error is final for this attempt. + expect(() => recover()).toThrow(InvalidCollectionStatusTransitionError) await flushPromises() + expect(collection.status).toBe(`error`) + expect(loads).toEqual([]) + // A restart begins a new attempt, which acquires the surviving demand. + removeErrorListener() + await collection.cleanup() + collection.startSyncImmediate() + await flushPromises() expect(collection.status).toBe(`ready`) expect(loads.map(({ where: loadedWhere }) => loadedWhere)).toEqual([ where, ]) - expect(observed).toEqual([expect.any(Promise)]) - await expect(observed[0]).resolves.toBeUndefined() - removeErrorListener() subscription.unsubscribe() await collection.cleanup() }, ) + // A cleaned-up attempt recovers with a restart. An errored attempt cannot + // recover in place; the test above owns its demand. it.each( - ([`error`, `cleaned-up`] as const).flatMap((unavailable) => + ([`cleaned-up`] as const).flatMap((unavailable) => ( [ `return`, @@ -3068,7 +3078,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { ), )( `observes unavailable demand synchronously: $entry / $unavailable / $outcome / $phase`, - async ({ unavailable, outcome, phase, entry }) => { + async ({ outcome, phase, entry }) => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const transport = createDeferred() const failure = new Error(`recovery acquisition failed`) @@ -3115,9 +3125,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { collection.createIndex((row) => row.id, { indexType: BTreeIndex }), ) } - if (unavailable === `error`) - operations.markError(new Error(`initial error`)) - else await collection.cleanup() + await collection.cleanup() const onLoadSubsetResult = ( value: true | Promise, _options: LoadSubsetOptions, @@ -3160,7 +3168,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(settlements).toEqual([]) expect(loads).toEqual([]) const recover = () => { - if (unavailable === `cleaned-up`) collection.startSyncImmediate() + collection.startSyncImmediate() operations.markReady() } if (phase === `during`) { @@ -3225,7 +3233,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) - it(`re-enables an installed loader after initial recovery in the same sync run`, async () => { + it(`keeps an installed loader unavailable after its initial error`, async () => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const loads: Array = [] let markError!: (error: unknown) => void @@ -3253,11 +3261,12 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }) markError(new Error(`initial sync failed`)) - markReady() + expect(() => markReady()).toThrow(InvalidCollectionStatusTransitionError) subscription.requestSnapshot({ where }) + await flushPromises() - expect(collection.status).toBe(`ready`) - expect(loads.map(({ where: loadedWhere }) => loadedWhere)).toEqual([where]) + expect(collection.status).toBe(`error`) + expect(loads).toEqual([]) subscription.unsubscribe() await collection.cleanup() @@ -3294,6 +3303,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { markError(new Error(`initial sync failed`)) subscription.requestSnapshot({ where }) subscription.releaseSnapshot(where) + // A restart is the only recovery; the released demand stays released. + await collection.cleanup() + collection.startSyncImmediate() markReady() await flushPromises() @@ -3306,7 +3318,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { acquisitionCase( [`on-demand:markError`], - `defers demand while an installed loader is in initial error`, + `keeps demand from an installed loader's initial error unacquired until a restart`, async (reach) => { const oldWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`old`)]) const newWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`new`)]) @@ -3346,11 +3358,23 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect.soft(collection.status).toBe(`error`) expect.soft(loads.map(({ where }) => where)).toEqual([oldWhere]) + expect(() => markReady()).toThrow(InvalidCollectionStatusTransitionError) + expect(collection.status).toBe(`error`) + + // A restart begins a new attempt, which acquires the surviving demand. + removeErrorListener() + await collection.cleanup() + collection.startSyncImmediate() markReady() await flushPromises() - expect(loads.map(({ where }) => where)).toEqual([oldWhere, newWhere]) + // The first attempt acquired `old` before its error. The new attempt + // acquires both surviving demands; `new` was never acquired before. + expect(loads.map(({ where }) => where)).toEqual([ + oldWhere, + oldWhere, + newWhere, + ]) - removeErrorListener() subscription.unsubscribe() await collection.cleanup() }, diff --git a/packages/db/tests/fixtures/development-only-messages.json b/packages/db/tests/fixtures/development-only-messages.json index 6624b4b3a3..653147b0ce 100644 --- a/packages/db/tests/fixtures/development-only-messages.json +++ b/packages/db/tests/fixtures/development-only-messages.json @@ -1,4 +1,12 @@ [ + { + "file": "collection/lifecycle.ts", + "literals": [ + "[TanStack DB] Collection \"", + "\" called markError() after its first ready. ", + "Call reportError(error, { final: true }) instead; the status stays ready." + ] + }, { "file": "collection/sync.ts", "literals": [ diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 38d4b408a1..1fe98bf5ea 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -923,5 +923,14 @@ "file": "local-storage.ts", "template": "LocalStorage sync belongs to its options Collection.", "literals": ["LocalStorage sync belongs to its options Collection."] + }, + "243": { + "file": "collection/lifecycle.ts", + "template": "${operation}() reports a problem after the first ready, but Collection \"${this.id}\" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up.", + "literals": [ + "() reports a problem after the first ready, but Collection \"", + "\" is ", + ". Before the first ready, retry without reporting, or call markError() when the source gives up." + ] } } diff --git a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts index 0f8e991d0b..7eaa4644dd 100644 --- a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts @@ -4,7 +4,8 @@ * The omission history uses delete/reinsert via public APIs, so a partial-row * mutant cannot hide behind draft undefined values or import truncation. * The status history observes caught receiver failure independently of promise - * fulfillment. Cleanup histories keep old native reads alive after retirement. + * fulfillment. After the first ready, that failure is final health, which the + * recorder adds to the status history. Cleanup histories keep old native reads alive after retirement. * Native scheduling itself is received separately by the browser suite. */ import { expect, it } from 'vitest' @@ -205,7 +206,7 @@ for (const abort of [false, true]) { }) } -it('reports an active receiver abort through Collection status while its callback fulfills', async () => { +it('reports an active receiver abort through Collection health while its callback fulfills', async () => { await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -224,8 +225,10 @@ it('reports an active receiver abort through Collection status while its callbac await gate.release() await receipt.completion expect(receipt.status).toBe('fulfilled') - expect(record.statuses).toContain('error') - expect(receiver.status).toBe('error') + // After the first ready a receiver failure is final health, not status. + expect(record.statuses).toContain('health-error') + expect(receiver.status).toBe('ready') + expect(receiver.health.error).toMatchObject({ phase: 'sync', final: true }) assertSnapshot( receiver.values(), [], diff --git a/packages/db/tests/indexed-db/recorder.ts b/packages/db/tests/indexed-db/recorder.ts index c949b152cc..4d0b543a71 100644 --- a/packages/db/tests/indexed-db/recorder.ts +++ b/packages/db/tests/indexed-db/recorder.ts @@ -15,7 +15,7 @@ export type RecordedEvent = { export function recordCollection( collection: Pick< Collection, - 'subscribeChanges' | 'on' | 'values' | 'status' + 'subscribeChanges' | 'on' | 'values' | 'status' | 'health' >, id: string, sink: (event: RecordedEvent) => void = () => {}, @@ -40,6 +40,11 @@ export function recordCollection( statuses.push(status) append({ type: 'status', status }) }) + // After the first ready, a source failure is health, not status. Record it + // in the status history so a status law still observes it. + const stopHealth = collection.on('health:change', () => { + if (collection.health.isError) statuses.push('health-error') + }) const subscription = collection.subscribeChanges( (changes) => { const publication: Publication = { @@ -69,6 +74,7 @@ export function recordCollection( events, stop: () => { stopStatus() + stopHealth() subscription.unsubscribe() }, } diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index cd3399bc3b..7b532f27f7 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -878,13 +878,20 @@ it('reports connection closure immediately and supports fresh Collections', asyn stores: ['items'], }) h.descriptors.push(upgraded) + // The source retires. A live query that was already ready keeps its rows + // and reports the source's error in its error tree. expect(old.status).toBe('error') - expect(live.status).toBe('error') + expect(live.status).toBe('ready') + expect(live.errors.sources[0]).toMatchObject({ + status: 'error', + isError: true, + }) const fresh = await h.open('items', { db: upgraded }) await fresh.insert({ id: 1, name: 'new' }).isPersisted.promise await Channel.deliver() expect(old.status).toBe('error') - expect(live.status).toBe('error') + expect(live.status).toBe('ready') + expect(live.errors.hasErrors).toBe(true) expect(fresh.status).toBe('ready') assertRows( fresh.values(), diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 7c2fc9f24f..b5f6097b8e 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -1723,7 +1723,10 @@ async function expectFailedConsolidationKeepsVisibleRows(): Promise { await expect(requests[3]!.outcome).rejects.toBe(failure) await flushPromises() - expect(live.status).toBe(`error`) + // After the first ready, the failed replacement is a request error on the + // live query's node. The query stays ready and keeps established rows. + expect(live.status).toBe(`ready`) + expect(live.errors.error).toMatchObject({ phase: `request` }) expect(live.utils.lastSubsetError).toBe(failure) expect(visibleCommentIds()).toEqual([200, 300]) } catch (error) { diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index f2f06cf423..7820bcbc71 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -2040,7 +2040,10 @@ describe(`createLiveQueryCollection`, () => { } expect(commitError).toBeUndefined() - expect(live.status).toBe(`error`) + // After the first ready, the failed request is a request error on the + // live query's node; the query stays ready. + expect(live.status).toBe(`ready`) + expect(live.errors.error).toMatchObject({ phase: `request` }) expect(live.utils.lastSubsetError).toBe(failure) } finally { await Promise.all([live.cleanup(), users.cleanup(), issues.cleanup()]) diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index d40af77337..687ed9e43f 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -357,7 +357,15 @@ describe(`loadSubset failure matrix`, () => { expect(() => triggerFailure()).not.toThrow() await flushFailures() - expect(live.status).toBe(path === `lazy` ? `error` : `ready`) + // After the first ready, a failed lazy request is a request error + // on the live query's node; the query keeps its status and rows. + expect(live.status).toBe(`ready`) + if (path === `lazy`) { + expect(live.errors).toMatchObject({ + isError: true, + error: { phase: `request`, final: false }, + }) + } if (failureValue === `error`) { expect(live.utils.lastSubsetError).toBe(error) } else { diff --git a/packages/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index efaa576aef..1409efc34b 100644 --- a/packages/db/tests/truncate-readiness-oracle.test.ts +++ b/packages/db/tests/truncate-readiness-oracle.test.ts @@ -7,20 +7,25 @@ * The reference fold is a sequence of authored replacements and status signals. * Every last truncate in one source transaction supplies that replacement's * readiness intent. Repeated transactions compose in publication order. We - * cross initial error/loading/ready, all two-truncate intent pairs, and a held + * cross initial loading/ready, all two-truncate intent pairs, and a held * optimistic transaction. Checks run inside public notifications as well as - * after commit, so transient false readiness cannot hide behind a final error. + * after commit, so transient false readiness cannot hide behind a later status. + * + * An initial error is final for its sync attempt, so a replacement that + * promises readiness after it is a contract violation and throws. Only a + * restart begins a new attempt. */ import { expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' +import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import type { SyncConfig } from '../src/types.js' type Row = { id: number; value: string } type Sync = Parameters['sync']>[0] type Intent = undefined | boolean const intents: Array = [undefined, true, false] -const cells = (['loading', 'ready', 'error'] as const).flatMap((status) => +const cells = (['loading', 'ready'] as const).flatMap((status) => [false, true].flatMap((held) => intents.flatMap((first) => intents.map((last) => ({ status, held, first, last })), @@ -55,12 +60,10 @@ it.each(cells)( sync.write({ type: 'insert', value: { id: 1, value: 'initial' } }) sync.commit() if (status === 'ready') sync.markReady() - // Author optimistic work before marking error; Collection admission rejects - // new edits once errored. Loading admits edits after sync entry. + // Loading admits edits after sync entry. const optimistic = held ? c.insert({ id: 9, value: 'optimistic' }) : undefined - if (status === 'error') sync.markError(new Error('retired source')) observed.length = 0 // Structural callable widening makes the pre-fix run reach assertions: // an ignored option is a semantic failure, never a compiler-error witness. @@ -88,7 +91,7 @@ it.each(cells)( finish.resolve() await optimistic?.isPersisted.promise expect.soft(c.status).toBe(expectedStatus) - // A later independent ordinary replacement still recovers normally. + // A later independent ordinary replacement is ready. sync.begin() sync.truncate() sync.commit() @@ -102,9 +105,44 @@ it.each(cells)( }, ) +it.each(intents)( + 'rejects a ready-promising replacement after the initial error: markReady %s', + async (markReady) => { + let sync!: Sync + const c = createCollection({ + getKey: (row) => row.id, + startSync: true, + gcTime: Infinity, + sync: { + sync: (params) => { + sync = params + }, + }, + }) + try { + sync.markError(new Error('initial sync failed')) + sync.begin() + sync.truncate(markReady === undefined ? undefined : { markReady }) + sync.write({ type: 'insert', value: { id: 1, value: 'late' } }) + if (markReady === false) { + // A replacement that keeps the status leaves the attempt in error. + sync.commit() + } else { + expect(() => sync.commit()).toThrow( + InvalidCollectionStatusTransitionError, + ) + } + expect(c.status).toBe('error') + } finally { + await c.cleanup() + } + }, +) + // Reentrant commits queue behind the publication currently notifying observers. // Their final replacement determines the next published batch, including its // readiness intent. An earlier replaced snapshot must not make that batch ready. +// The Collection starts loading, so only a replacement can make it ready. it.each(intents.flatMap((first) => intents.map((last) => ({ first, last }))))( 'composes readiness of reentrant replacement batches: %j', async ({ first, last }) => { @@ -116,7 +154,6 @@ it.each(intents.flatMap((first) => intents.map((last) => ({ first, last }))))( sync: { sync: (params) => { sync = params - params.markReady() }, }, }) @@ -129,7 +166,6 @@ it.each(intents.flatMap((first) => intents.map((last) => ({ first, last }))))( const subscription = c.subscribeChanges(() => { if (entered) return entered = true - sync.markError(new Error('source closed')) for (const [index, markReady] of [first, last].entries()) { sync.begin() sync.truncate(markReady === undefined ? undefined : { markReady }) @@ -146,8 +182,8 @@ it.each(intents.flatMap((first) => intents.map((last) => ({ first, last }))))( sync.commit() await Promise.all(receipts) expect(entered).toBe(true) - expect(c.status).toBe(last === false ? 'error' : 'ready') - expect(statuses).toEqual(last === false ? ['error'] : ['error', 'ready']) + expect(c.status).toBe(last === false ? 'loading' : 'ready') + expect(statuses).toEqual(last === false ? [] : ['ready']) expect([...c.keys()]).toEqual([3]) } finally { subscription.unsubscribe() From cec4e5a14bc8b3fbe6fd7cdd01227d5ec2732d0d Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 16:38:58 -0600 Subject: [PATCH 004/106] test(db): add the source failure and health oracle An independent model of each source's gate and health problem, and of the live query gates they imply, predicts the full error tree after every step. Generated histories cross ready, markError, reportError (final or not), reportHealthy, and restart over direct, join, self-join, nested, and include shapes. A request lane checks that a lazy load that fails after ready is a request error that clears when its demand settles or is released, and that the same failure before ready fails the attempt. Nine production mutants fail at assertions: health that flips the status, a nested query that does not relay, a cleared final problem, a tree that drops healthy nodes, error-to-ready healing, a missing health:change relay, a request error that does not clear on settle, a request failure that ends the query, and a health report before the first ready. Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 1 + .../db/tests/source-health-oracle.test.ts | 682 ++++++++++++++++++ 2 files changed, 683 insertions(+) create mode 100644 packages/db/tests/source-health-oracle.test.ts diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index cb3ac26996..ab22661467 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -25,6 +25,7 @@ const staticOracleProperties = [ `sqlite-persistence.open-transaction-boundary`, `sqlite-persistence.request-local-failure-buffered-source`, `trailbase.lifecycle`, + `source-health.history`, `electric.bound-descriptor-history`, `electric.persisted-tag-history`, `electric.match-reentry`, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts new file mode 100644 index 0000000000..d93f8bb649 --- /dev/null +++ b/packages/db/tests/source-health-oracle.test.ts @@ -0,0 +1,682 @@ +/** + * # Source failure and health + * + * A Collection answers two questions on two separate axes: + * + * - **The gate (`status`)** says whether the data can be shown yet. It is + * decided only by the initial sync of a sync attempt. While a source retries + * it stays `loading`. When the source gives up before its first ready + * (`markError`), the attempt fails for good: the status is `error`, and a + * later `markReady()` in the same attempt is a contract violation that + * throws. Only a restart (`cleanup()`, then a new preload or subscriber) + * begins a new attempt. + * - **Health** says whether the data is still kept up to date. It is metadata + * after the first ready: `reportError(error, { final })` records a problem, + * `reportHealthy()` clears a non-final one, and `markError()` after ready is + * a final problem. The status and the rows do not change. Only the source + * clears its problem, and nothing clears a final problem except a restart. + * + * A live query exposes both axes for everything it depends on through + * `collection.errors`: one node for itself and one per source alias in query + * order, recursing through nested live queries. The tree always has the same + * shape for a given query. Each node mirrors its Collection at the moment it + * is read, and the live query emits `health:change` when a node below it + * changes, so a reader knows to read the tree again. + * + * A live query's own gate follows its sources: it becomes ready when every + * source is ready, and a source that fails before the query's first ready + * fails the query's attempt, whatever the query's shape. After the query's + * first ready, its sources can only report health, and the query keeps its + * status and rows. + * + * ## The model + * + * The model keeps, for each source, its gate and its health problem (none, + * non-final, or final). It derives each live query's gate from its sources, + * using only the rules above, and builds the expected tree from those facts. + * It does not read production state. + * + * ## The history grammar + * + * Steps act on source `A` or `B`: `ready` writes the source's row and calls + * `markReady()`; `fail` calls `markError()`; `report` and `reportFinal` call + * `reportError()`; `healthy` calls `reportHealthy()`. `restart` cleans up the + * live query and every source, then preloads the live query again. Every step + * is issued in every state. The model predicts its outcome, including a throw + * for `markReady()` after `markError()`, and for a health report before the + * first ready. Five shapes cross the grammar: a direct source, a join, a + * self-join of one Collection under two aliases, a nested live query, and an + * include child. + * + * ## The comparison + * + * After every step the driver compares, with the model: the live query's + * status, its row count while ready, the full projected error tree, and the + * thrown error class. When the expected tree changed, it also checks that the + * live query emitted `health:change`. A request lane checks that a lazy load + * that fails after the first ready is a request error on the live query's + * node, which clears when that demand settles or is released. + * + * ## Limits + * + * Sources are eager, so a live query cannot become ready while a source is + * still loading. The on-demand case, where a source can fail its initial sync + * after the live query is ready, is not generated here. The include node is + * one node for the include's source Collection; per-row child Collections are + * not nodes. Pooled live queries and Effects are outside this owner. + */ +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { eq } from '../src/query/builder/functions.js' +import { toArray } from '../src/query/index.js' +import { InvalidCollectionStatusTransitionError } from '../src/errors.js' +import { oraclePropertyOptions } from './oracle-config.js' +import { flushPromises } from './utils.js' +import type { Collection } from '../src/collection/index.js' +import type { + CollectionStatus, + ErrorTreeNode, + LoadSubsetOptions, + SyncConfig, +} from '../src/types.js' + +type Row = { id: number; parentId: number } +type SyncParams = Parameters[`sync`]>[0] +type SourceName = `A` | `B` +type Shape = `direct` | `join` | `self-join` | `nested` | `include` +type Step = + | { + kind: `ready` | `fail` | `report` | `reportFinal` | `healthy` + source: SourceName + } + | { kind: `restart` } + +// --------------------------------------------------------------------------- +// Independent model +// --------------------------------------------------------------------------- + +type Gate = `loading` | `ready` | `error` +type Problem = null | { final: boolean } +type SourceModel = { gate: Gate; problem: Problem } +type Model = { + sources: Record + query: Gate + inner: Gate +} + +const shapeSources: Record> = { + direct: [`A`], + join: [`A`, `B`], + 'self-join': [`A`], + nested: [`A`, `B`], + include: [`A`, `B`], +} + +function freshModel(): Model { + const source = (): SourceModel => ({ gate: `loading`, problem: null }) + return { + sources: { A: source(), B: source() }, + query: `loading`, + inner: `loading`, + } +} + +// A live query's gate follows its sources and never leaves error or ready +// within an attempt. +function deriveGate(current: Gate, inputs: ReadonlyArray): Gate { + if (current !== `loading`) return current + if (inputs.some((gate) => gate === `error`)) return `error` + if (inputs.every((gate) => gate === `ready`)) return `ready` + return `loading` +} + +function deriveQueries(shape: Shape, model: Model): void { + const gates = (names: ReadonlyArray) => + names.map((name) => model.sources[name].gate) + if (shape === `nested`) { + model.inner = deriveGate(model.inner, gates([`A`])) + model.query = deriveGate(model.query, [model.inner, model.sources.B.gate]) + } else { + model.query = deriveGate(model.query, gates(shapeSources[shape])) + } +} + +/** Apply a step. Returns `throws` when the contract forbids it. */ +function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { + if (step.kind === `restart`) { + Object.assign(model, freshModel()) + return `ok` + } + // A step on a source this shape does not read has no effect. + if (!shapeSources[shape].includes(step.source)) return `ok` + const source = model.sources[step.source] + let outcome: `ok` | `throws` = `ok` + switch (step.kind) { + case `ready`: + if (source.gate === `error`) outcome = `throws` + else source.gate = `ready` + break + case `fail`: + // Before the first ready the attempt fails; after it, a final problem. + if (source.gate === `ready`) source.problem = { final: true } + else source.gate = `error` + break + case `report`: + case `reportFinal`: + if (source.gate !== `ready`) outcome = `throws` + else if (!source.problem?.final) + source.problem = { final: step.kind === `reportFinal` } + break + case `healthy`: + if (source.gate !== `ready`) outcome = `throws` + else if (source.problem && !source.problem.final) source.problem = null + break + } + deriveQueries(shape, model) + return outcome +} + +type ExpectedNode = { + alias: string + kind: `source` | `live-query` + status: Gate + isError: boolean + hasErrors: boolean + error: null | { phase: `initial` | `sync`; final: boolean } + sources: Array +} + +function sourceNode(alias: string, source: SourceModel): ExpectedNode { + const error = + source.gate === `error` + ? { phase: `initial` as const, final: true } + : source.problem + ? { phase: `sync` as const, final: source.problem.final } + : null + return { + alias, + kind: `source`, + status: source.gate, + isError: error !== null, + hasErrors: error !== null, + error, + sources: [], + } +} + +function queryNode( + alias: string, + gate: Gate, + sources: Array, +): ExpectedNode { + const error = + gate === `error` ? { phase: `initial` as const, final: true } : null + return { + alias, + kind: `live-query`, + status: gate, + isError: error !== null, + hasErrors: error !== null || sources.some((node) => node.hasErrors), + error, + sources, + } +} + +function expectedTree(shape: Shape, model: Model): ExpectedNode { + const { A, B } = model.sources + switch (shape) { + case `direct`: + return queryNode(`root`, model.query, [sourceNode(`a`, A)]) + case `join`: + return queryNode(`root`, model.query, [ + sourceNode(`a`, A), + sourceNode(`b`, B), + ]) + case `self-join`: + return queryNode(`root`, model.query, [ + sourceNode(`first`, A), + sourceNode(`second`, A), + ]) + case `nested`: + return queryNode(`root`, model.query, [ + queryNode(`inner`, model.inner, [sourceNode(`a`, A)]), + sourceNode(`b`, B), + ]) + case `include`: + return queryNode(`root`, model.query, [ + sourceNode(`b`, B), + sourceNode(`a`, A), + ]) + } +} + +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- + +function createSource(name: SourceName) { + let params: SyncParams | undefined + const collection = createCollection({ + id: `health-oracle-${name}-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + startSync: false, + gcTime: 0, + sync: { + sync: (next) => { + params = next + }, + }, + }) + return { + collection, + get params(): SyncParams { + if (!params) throw new Error(`source ${name} has not started`) + return params + }, + } +} + +function buildQuery( + shape: Shape, + A: Collection, + B: Collection, +) { + switch (shape) { + case `direct`: + return { query: createLiveQueryCollection((q) => q.from({ a: A })) } + case `join`: + return { + query: createLiveQueryCollection((q) => + q.from({ a: A }).join({ b: B }, ({ a, b }) => eq(a.id, b.id)), + ), + } + case `self-join`: + return { + query: createLiveQueryCollection((q) => + q + .from({ first: A }) + .join({ second: A }, ({ first, second }) => + eq(first.id, second.id), + ), + ), + } + case `nested`: { + const inner = createLiveQueryCollection((q) => q.from({ a: A })) + return { + inner, + query: createLiveQueryCollection((q) => + q + .from({ inner }) + .join({ b: B }, ({ inner: row, b }) => eq(row.id, b.id)), + ), + } + } + case `include`: + return { + query: createLiveQueryCollection((q) => + q.from({ b: B }).select(({ b }) => ({ + id: b.id, + items: toArray( + q.from({ a: A }).where(({ a }) => eq(a.parentId, b.id)), + ), + })), + ), + } + } +} + +// The public tree, projected onto the fields the model owns. +function project(node: ErrorTreeNode): ExpectedNode { + return { + alias: node.alias, + kind: node.kind, + status: node.status as Gate, + isError: node.isError, + hasErrors: node.hasErrors, + error: node.error + ? { + phase: node.error.phase as `initial` | `sync`, + final: node.error.final, + } + : null, + sources: node.sources.map(project), + } +} + +async function runHistory( + shape: Shape, + steps: ReadonlyArray, +): Promise { + const sources = { A: createSource(`A`), B: createSource(`B`) } + const { query, inner } = buildQuery( + shape, + sources.A.collection, + sources.B.collection, + ) + const model = freshModel() + let events = 0 + let stop = () => {} + // Cleanup without subscribers drops event listeners, so each attempt + // registers its own. + const start = () => { + stop() + stop = query.on(`health:change`, () => { + events++ + }) + void query.preload().catch(() => undefined) + } + const consoleError = console.error + console.error = () => {} + try { + start() + await flushPromises() + for (const [index, step] of steps.entries()) { + const label = `step ${index} ${JSON.stringify(step)} on ${shape}` + const before = JSON.stringify(expectedTree(shape, model)) + const expected = applyModel(shape, model, step) + events = 0 + let thrown: unknown + try { + if (step.kind === `restart`) { + await query.cleanup() + await inner?.cleanup() + await Promise.all([ + sources.A.collection.cleanup(), + sources.B.collection.cleanup(), + ]) + start() + } else if (shapeSources[shape].includes(step.source)) { + const params = sources[step.source].params + if (step.kind === `ready`) { + if (params.collection.status === `loading`) { + params.begin() + params.write({ + type: `insert`, + value: { id: 1, parentId: 1 }, + }) + params.commit() + } + params.markReady() + } else if (step.kind === `fail`) { + params.markError(new Error(`${step.source} gave up`)) + } else if (step.kind === `healthy`) { + params.reportHealthy() + } else { + params.reportError(new Error(`${step.source} problem`), { + final: step.kind === `reportFinal`, + }) + } + } + } catch (error) { + thrown = error + } + await flushPromises() + + if (expected === `throws`) { + expect(thrown, `${label}: contract violation throws`).toBeDefined() + if (step.kind === `ready`) { + expect(thrown, label).toBeInstanceOf( + InvalidCollectionStatusTransitionError, + ) + } + } else { + expect(thrown, `${label}: legal step`).toBeUndefined() + } + expect(query.status, `${label}: gate`).toBe( + model.query as CollectionStatus, + ) + if (model.query === `ready`) { + expect(query.size, `${label}: rows stay while ready`).toBe(1) + } + const tree = expectedTree(shape, model) + expect(project(query.errors), `${label}: error tree`).toEqual(tree) + if (step.kind !== `restart` && JSON.stringify(tree) !== before) { + expect(events, `${label}: health:change relay`).toBeGreaterThan(0) + } + } + } finally { + console.error = consoleError + stop() + await query.cleanup() + await inner?.cleanup() + await Promise.all([ + sources.A.collection.cleanup(), + sources.B.collection.cleanup(), + ]) + } +} + +// --------------------------------------------------------------------------- +// Histories +// --------------------------------------------------------------------------- + +const shapes: ReadonlyArray = [ + `direct`, + `join`, + `self-join`, + `nested`, + `include`, +] +const on = ( + kind: Exclude, + source: SourceName, +): Step => ({ + kind, + source, +}) +const restart: Step = { kind: `restart` } + +// Named histories, one per distinction the law draws. +const namedHistories: Record> = { + 'a non-final problem clears when the source reports healthy': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + on(`healthy`, `A`), + ], + 'a final problem stays until a restart': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reportFinal`, `A`), + on(`healthy`, `A`), + restart, + on(`ready`, `A`), + on(`ready`, `B`), + ], + 'markError after the first ready is final health': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`fail`, `A`), + on(`healthy`, `A`), + ], + 'an initial error is final for its attempt': [ + on(`fail`, `A`), + on(`ready`, `A`), + on(`ready`, `B`), + ], + 'a restart recovers an attempt that failed': [ + on(`fail`, `A`), + restart, + on(`ready`, `A`), + on(`ready`, `B`), + ], + 'a health report before the first ready throws': [ + on(`report`, `A`), + on(`healthy`, `B`), + on(`ready`, `A`), + ], +} + +describe(`source failure and health oracle`, () => { + for (const shape of shapes) { + for (const [name, steps] of Object.entries(namedHistories)) { + it(`${shape}: ${name}`, () => runHistory(shape, steps)) + } + } + + const stepArb: fc.Arbitrary = fc.oneof( + { weight: 1, arbitrary: fc.constant(restart) }, + { + weight: 6, + arbitrary: fc + .tuple( + fc.constantFrom( + `ready` as const, + `fail` as const, + `report` as const, + `reportFinal` as const, + `healthy` as const, + ), + fc.constantFrom(`A` as const, `B` as const), + ) + .map(([kind, source]) => on(kind, source)), + }, + ) + fcTest.prop( + [fc.constantFrom(...shapes), fc.array(stepArb, { maxLength: 8 })], + oraclePropertyOptions(60, `source-health.history`), + )(`generated histories refine the model`, (shape, steps) => + runHistory(shape, steps), + ) +}) + +// --------------------------------------------------------------------------- +// Request lane +// --------------------------------------------------------------------------- + +/** + * A live query's lazy source loads only the keys its rows reach. After the + * query's first ready, a load that fails is a request error on the query's + * own node: the source stays healthy and the query keeps its status. It + * clears when the same demand later settles, or when the query stops needing + * it. Before the first ready, the same failure fails the query's attempt. + */ +describe(`request errors`, () => { + function createRequestHistory() { + let parentParams!: SyncParams + const outcomes: Array<`ok` | `fail`> = [] + const parents = createCollection({ + id: `health-request-parents-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + parentParams = params + }, + }, + }) + const children = createCollection({ + id: `health-request-children-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (_options: LoadSubsetOptions) => { + const outcome = outcomes.shift() ?? `ok` + return outcome === `ok` + ? Promise.resolve() + : Promise.reject(new Error(`child load failed`)) + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q + .from({ parent: parents }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + ) + const writeParent = (type: `insert` | `delete`, id: number) => { + parentParams.begin() + parentParams.write({ type, value: { id, parentId: id } }) + parentParams.commit() + } + const markParentsReady = () => parentParams.markReady() + return { parents, children, query, outcomes, writeParent, markParentsReady } + } + + async function withHistory( + run: (history: ReturnType) => Promise, + ) { + const history = createRequestHistory() + const consoleError = console.error + console.error = () => {} + try { + await run(history) + } finally { + console.error = consoleError + await history.query.cleanup() + await history.parents.cleanup() + await history.children.cleanup() + } + } + + it(`a failed load after ready is a request error that clears when its demand settles`, () => + withHistory( + async ({ query, children, outcomes, writeParent, markParentsReady }) => { + const preload = query.preload() + writeParent(`insert`, 1) + markParentsReady() + await preload + expect(query.errors.isError).toBe(false) + + outcomes.push(`fail`) + writeParent(`insert`, 2) + await flushPromises() + expect(query.status).toBe(`ready`) + expect(query.errors.error).toMatchObject({ + phase: `request`, + final: false, + }) + expect(children.health.isError).toBe(false) + + // The demand changes again and its new load settles. + outcomes.push(`ok`) + writeParent(`insert`, 3) + await flushPromises() + expect(query.errors.isError).toBe(false) + }, + )) + + it(`a request error clears when the query stops needing the demand`, () => + withHistory(async ({ query, outcomes, writeParent, markParentsReady }) => { + const preload = query.preload() + writeParent(`insert`, 1) + markParentsReady() + await preload + + outcomes.push(`fail`) + writeParent(`insert`, 2) + await flushPromises() + expect(query.errors.error?.phase).toBe(`request`) + + writeParent(`delete`, 1) + writeParent(`delete`, 2) + await flushPromises() + expect(query.errors.isError).toBe(false) + expect(query.status).toBe(`ready`) + })) + + it(`a failed load before ready fails the query's attempt`, () => + withHistory(async ({ query, outcomes, writeParent, markParentsReady }) => { + outcomes.push(`fail`) + const preload = query.preload() + void preload.catch(() => undefined) + writeParent(`insert`, 1) + markParentsReady() + await flushPromises() + expect(query.status).toBe(`error`) + expect(query.errors.error).toMatchObject({ + phase: `initial`, + final: true, + }) + await expect(preload).rejects.toBeDefined() + })) +}) From 3ffc6d55e5c72d99b73465b121790d5c427ffdad Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 16:51:33 -0600 Subject: [PATCH 005/106] feat(db): report health to Effects and expose errors to live-query readers An Effect latches its first ready at the same checkpoints that end skipInitial suppression. After it, a source problem no longer disposes the Effect; the new onHealthChange option receives the error tree. A source failure before the first ready still disposes it through onSourceError. The live-query snapshot that every framework adapter reads gains an errors tree, refreshed on health:change, while isError keeps describing only the initial load. A pooled live query exposes the same tree. ARCHITECTURE.md now states the final initial-error law and the error tree as laws 16 and 17, and the restart contract no longer describes same-attempt recovery. The health oracle gains an Effect lane: a failure before ready disposes the Effect, and after ready an eager or on-demand source problem reaches onHealthChange without disposal. Co-authored-by: Isaac --- docs/errors.md | 10 ++ packages/db/src/collection/lifecycle.ts | 3 +- packages/db/src/live-query-observer.ts | 16 ++- packages/db/src/query/effect.ts | 65 +++++++++++- packages/db/src/query/live/ARCHITECTURE.md | 26 ++++- packages/db/src/query/pooled-live-query.ts | 44 ++++++++- .../tests/fixtures/error-site-messages.json | 5 + .../db/tests/source-health-oracle.test.ts | 99 +++++++++++++++++++ 8 files changed, 255 insertions(+), 13 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index f78e77b005..80923754d5 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2459,3 +2459,13 @@ Development builds show this message, with `${...}` replaced by values: ```text ${operation}() reports a problem after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up. ``` + + + +## Error 244: `query/pooled-live-query.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +The pooled live query's source ended +``` diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3cf80977f5..ce8484ed4c 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -612,7 +612,8 @@ export class CollectionLifecycleManager< } } -function describeProblem( +/** @internal Error tree detail for a problem value. */ +export function describeProblem( error: unknown, phase: CollectionErrorInfo[`phase`], final: boolean, diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 0dab756aa0..c4e6549c24 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -10,7 +10,7 @@ import { getPersistedReadinessSource } from './persisted-readiness.js' import type { Collection } from './collection/index.js' import type { DbClient, DehydratedLiveQueryResult } from './client.js' import type { PersistedReadinessSource } from './persisted-readiness.js' -import type { ChangeMessage, CollectionStatus } from './types.js' +import type { ChangeMessage, CollectionStatus, ErrorTreeNode } from './types.js' export type LiveQueryPersistedStatus = `unavailable` | `loading` | `ready` | `error` @@ -96,6 +96,13 @@ export interface LiveQuerySnapshot< isError: boolean isCleanedUp: boolean isEnabled: boolean + /** + * The error tree for the query and every Collection it depends on, or + * `undefined` for a disabled query. `isError` describes only the initial + * load; after the first ready, a source problem shows here and the data + * stays. + */ + errors: ErrorTreeNode | undefined } /** @@ -185,6 +192,7 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot = { isError: false, isCleanedUp: false, isEnabled: false, + errors: undefined, } class LiveQueryObserverImpl< @@ -324,6 +332,7 @@ class LiveQueryObserverImpl< isPersistedReady: persistedStatus === `ready`, persistedError: local.error, isEnabled: true, + errors: (collection as { errors?: ErrorTreeNode }).errors, } this.snapshotDirty = false } @@ -754,6 +763,10 @@ class LiveQueryObserverImpl< const statusUnsub = collection.on(`status:change`, ({ status }) => notify(undefined, status), ) + // A health change below the query changes its error tree, not its rows. + const healthUnsub = collection.on(`health:change`, () => + notify(undefined, collection.status), + ) const subscribeLayoutChanges = ( collection as Collection & { _subscribeLayoutChanges?: (listener: () => void) => () => void @@ -809,6 +822,7 @@ class LiveQueryObserverImpl< const release = () => { clientUnsub() statusUnsub() + healthUnsub() layoutUnsub() persistedUnsubs?.forEach((unsubscribe) => unsubscribe()) subscription?.unsubscribe() diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 7d699b83f8..09a85c1dc1 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -5,6 +5,7 @@ import { runAllCallbacks } from '../utils/callbacks.js' import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' +import { buildErrorTree } from '../collection/error-tree.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -39,6 +40,7 @@ import type { } from './compiler/joins.js' import type { ChangeMessage, + ErrorTreeNode, KeyedStream, StringCollationConfig, } from '../types.js' @@ -131,12 +133,19 @@ export interface EffectConfig< onError?: (error: Error, event: DeltaEvent) => void /** - * Called when a source collection enters an error or cleaned-up state. - * The effect is automatically disposed after this callback fires. - * If not provided, the error is logged to console.error. + * Called when a source collection fails before the effect's first ready, or + * is cleaned up. The effect is automatically disposed after this callback + * fires. If not provided, the error is logged to console.error. */ onSourceError?: (error: Error) => void + /** + * Called after the effect's first ready whenever a source's status or health + * changes. A source problem after the first ready does not dispose the + * effect; read the error tree to decide what to do. + */ + onHealthChange?: (errors: ErrorTreeNode) => void + /** * Skip deltas during initial collection load. * Defaults to false (process all deltas including initial sync). @@ -305,6 +314,7 @@ export function createEffect< query: config.query, skipInitial: config.skipInitial ?? false, onBatchProcessed, + onHealthChange: config.onHealthChange, onSourceError: (error: Error) => { if (disposed) return @@ -375,6 +385,7 @@ interface EffectPipelineRunnerConfig< onBatchProcessed: (events: Array>) => void /** Called when a source collection enters error or cleaned-up state */ onSourceError: (error: Error) => void + onHealthChange?: (errors: ErrorTreeNode) => void } /** @@ -458,6 +469,10 @@ class EffectPipelineRunner { // skipInitial state private readonly skipInitial: boolean private initialLoadComplete = false + // Latched when the effect's initial load can complete, with or without + // skipInitial. After it, a source problem is health, not disposal. + private reachedReady = false + private readonly onHealthChange: ((errors: ErrorTreeNode) => void) | undefined // Scheduler integration private subscribedToAllCollections = false @@ -477,6 +492,7 @@ class EffectPipelineRunner { this.skipInitial = config.skipInitial this.onBatchProcessed = config.onBatchProcessed this.onSourceError = config.onSourceError + this.onHealthChange = config.onHealthChange // Parse query this.query = buildQueryFromConfig({ query: config.query }) @@ -713,7 +729,12 @@ class EffectPipelineRunner { const { status } = event - // Source entered error state — effect can no longer function + // A source failure before the effect's first ready ends the effect. + // After it, the failure is health and the effect keeps running. + if (status === `error` && this.reachedReady) { + this.reportHealth() + return + } if (status === `error`) { this.onSourceError( new Error( @@ -739,8 +760,13 @@ class EffectPipelineRunner { ) { this.initialLoadComplete = true } + const wasReady = this.reachedReady + if (this.latchReady() && wasReady) this.reportHealth() }) this.unsubscribeCallbacks.add(statusUnsubscribe) + this.unsubscribeCallbacks.add( + collection.on(`health:change`, () => this.reportHealth()), + ) const cleanupStartUnsubscribe = collection._onCleanupStart(handleSourceCleanup) // Registration reports an already-active cleanup synchronously. That @@ -778,6 +804,7 @@ class EffectPipelineRunner { // Initial graph run to process any synchronously-available data. // For skipInitial, this run's output is discarded (initialLoadComplete is still false). this.runGraph() + this.latchReady() // After the initial graph run, if all sources are ready, // mark initial load as complete so future events are processed. @@ -968,6 +995,7 @@ class EffectPipelineRunner { } // Emit all accumulated events once the graph reaches quiescence this.flushPendingChanges() + this.latchReady() if ( this.skipInitial && !this.initialLoadComplete && @@ -1030,6 +1058,35 @@ class EffectPipelineRunner { } /** Initial callbacks start only after every source served its initial data. */ + /** Latch the effect's first ready and report whether it has happened. */ + private latchReady(): boolean { + if (!this.reachedReady && this.canCompleteInitialLoad()) + this.reachedReady = true + return this.reachedReady + } + + /** The error tree for this effect's sources, in query order. */ + private errorTree(): ErrorTreeNode { + const sources = this.collectionSources.map(({ alias, collection }) => + buildErrorTree(collection, alias), + ) + return { + alias: `root`, + collectionId: `effect`, + kind: `live-query`, + status: this.reachedReady ? `ready` : `loading`, + isError: false, + hasErrors: sources.some((node) => node.hasErrors), + error: null, + sources, + } + } + + private reportHealth(): void { + if (this.disposed || !this.reachedReady || !this.onHealthChange) return + this.onHealthChange(this.errorTree()) + } + private canCompleteInitialLoad(): boolean { return ( !this.orderedPublicationFailed && diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index a83d71be47..df6a42a1d6 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -825,16 +825,18 @@ live query itself. This differs from a source truncate, which keeps the live query active behind its replay publication barrier. An initial sync error also leaves newly requested demand detached, even when -the adapter has installed a loader. `markReady()` in the same sync run resumes that -demand; releasing it before recovery creates no physical acquisition or unload. -Queued reacquisition must not retry a failed attempt merely because both -loading and ready notifications scheduled it. +the adapter has installed a loader. The error is final for its sync attempt: +`markReady()` in the same sync run is a contract violation and throws, and +only a restart, which begins a new sync run, acquires that demand. Releasing it +before the restart creates no physical acquisition or unload. Queued +reacquisition must not retry a failed attempt merely because both loading and +ready notifications scheduled it. Requests waiting for a loader report one pending promise synchronously through `onLoadSubsetResult`, including requests made during initial error or after cleanup. The callback is not delayed until acquisition: query callers capture its result before the snapshot request returns. This promise waits for the -recovery's publication barrier, not just adapter return. Failure rejects it with +restart's publication barrier, not just adapter return. Failure rejects it with the replay error; release, external abort, unsubscribe, or another cleanup rejects it with `AbortError`. Later transport settlement cannot change that outcome. Cleanup may retain logical demand for the next sync run, but it does @@ -1566,6 +1568,20 @@ create recursive Collection machinery. counts as a preload. A deferring live query keeps its sources from garbage collection but is not counted in their `subscriberCount` or `subscribers:change`, which adapters read as a request for live data. +16. **Final initial errors and source health:** a sync attempt's initial + error is final for that attempt; a Collection moves from `error` to + `ready` only through a restart. Before a live query's first ready, a + source that fails fails the query's attempt, for every shape. After its + first ready, a source's later problem is health metadata: the query keeps + its status and rows, and its error tree reports the problem. A lazy load + that fails after the first ready is a request error on the query's node, + cleared when that demand settles or is released. Query failures and + contract violations, such as a graph failure or a source cleaned up under + the query, still end the query. +17. **Error tree:** a live query's `errors` has one node for itself and one per + source alias in query order, recursing through nested live queries. The + shape is fixed by the query. Each node mirrors its Collection when read, + and the query emits `health:change` whenever a node below it changes. ## Glossary diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 9f87605ff3..78e9722c8a 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -1,6 +1,11 @@ import { SortedMap } from '../SortedMap.js' +import { codedMessage, devBuild } from '../error-message.js' import { CleanupQueue } from '../collection/cleanup-queue.js' -import { UNSUBSCRIBED_GC_FLOOR_MS } from '../collection/lifecycle.js' +import { + UNSUBSCRIBED_GC_FLOOR_MS, + describeProblem, +} from '../collection/lifecycle.js' +import { buildErrorTree } from '../collection/error-tree.js' import { makeComparator } from '../utils/comparison.js' import { isVirtualPropName } from '../virtual-props.js' import { getPersistedReadinessSource } from '../persisted-readiness.js' @@ -13,7 +18,11 @@ import type { BasicExpression, OrderBy, QueryIR } from './ir.js' import type { BaseQueryBuilder } from './builder/index.js' import type { Collection, CollectionImpl } from '../collection/index.js' import type { CollectionSubscription } from '../collection/subscription.js' -import type { ChangeMessage, CollectionStatus } from '../types.js' +import type { + ChangeMessage, + CollectionStatus, + ErrorTreeNode, +} from '../types.js' import type { CollectionEventHandler, CollectionStatusChangeEvent, @@ -494,6 +503,7 @@ class PooledLiveQuery { private readonly gcTime: number, // The query's conjuncts beyond its group's equalities, if any. private readonly passes: ((row: Row) => boolean) | undefined, + private readonly alias: string, ) { partition.retain(gcTime) } @@ -502,6 +512,35 @@ class PooledLiveQuery { return this.partition.terminated ? `error` : this.source.status } + /** The same error tree a live-query Collection over this source exposes. */ + get errors(): ErrorTreeNode { + const source = buildErrorTree(this.source, this.alias) + // A terminated partition ended this view, for example when its source + // was cleaned up under it. + const error = this.partition.terminated + ? (this.source._lifecycle.gateProblem ?? + describeProblem( + new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `The pooled live query's source ended` + : codedMessage(244), + ), + `initial`, + true, + )) + : null + return { + alias: `root`, + collectionId: this.source.id, + kind: `live-query`, + status: this.status, + isError: error !== null, + hasErrors: error !== null || source.hasErrors, + error, + sources: [source], + } + } + // Read by key: the partition drops a group nobody watches once it empties. private get group(): PartitionGroup { return this.partition.peek(this.groupKey) @@ -722,5 +761,6 @@ export function createPooledLiveQuery( shape.residual.length > 0 ? rowPredicate(shape.residual, ir.from.alias) : undefined, + ir.from.alias, ) as unknown as Collection } diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 1fe98bf5ea..bc27acac23 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -932,5 +932,10 @@ "\" is ", ". Before the first ready, retry without reporting, or call markError() when the source gives up." ] + }, + "244": { + "file": "query/pooled-live-query.ts", + "template": "The pooled live query's source ended", + "literals": ["The pooled live query's source ended"] } } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index d93f8bb649..42be145cd3 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -69,6 +69,7 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { createEffect } from '../src/query/effect.js' import { eq } from '../src/query/builder/functions.js' import { toArray } from '../src/query/index.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' @@ -680,3 +681,101 @@ describe(`request errors`, () => { await expect(preload).rejects.toBeDefined() })) }) + +// --------------------------------------------------------------------------- +// Effect lane +// --------------------------------------------------------------------------- + +/** + * An Effect has the same two axes. A source failure before the Effect's first + * ready disposes it through `onSourceError`. After its first ready, a source + * problem does not dispose it: `onHealthChange` receives the error tree, with + * one node per source alias. An on-demand source can fail its own initial + * sync after the Effect is ready, because the Effect's readiness follows its + * settled loads; that failure is health too. + */ +describe(`effect health`, () => { + type EffectHistory = { + syncMode: `eager` | `on-demand` + failBeforeReady: boolean + failure: `markError` | `reportFinal` + } + const histories: Array = [ + { syncMode: `eager`, failBeforeReady: true, failure: `markError` }, + { syncMode: `eager`, failBeforeReady: false, failure: `markError` }, + { syncMode: `eager`, failBeforeReady: false, failure: `reportFinal` }, + { syncMode: `on-demand`, failBeforeReady: false, failure: `markError` }, + ] + + it.each(histories)(`%j`, async ({ syncMode, failBeforeReady, failure }) => { + let params!: SyncParams + const source = createCollection({ + id: `health-effect-${syncMode}-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode, + gcTime: 0, + sync: { + sync: (next) => { + params = next + if (syncMode === `on-demand`) return { loadSubset: () => true } + return undefined + }, + }, + }) + const sourceErrors: Array = [] + const trees: Array = [] + const effect = createEffect({ + query: (q) => q.from({ row: source }), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), + }) + const consoleError = console.error + console.error = () => {} + try { + await flushPromises() + if (failBeforeReady) { + params.markError(new Error(`gave up`)) + await flushPromises() + // Model: the Effect never reached ready, so the failure ends it. + expect(effect.disposed).toBe(true) + expect(sourceErrors).toHaveLength(1) + expect(trees).toEqual([]) + return + } + if (syncMode === `eager`) { + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + params.markReady() + await flushPromises() + } + if (failure === `markError`) params.markError(new Error(`gave up`)) + else params.reportError(new Error(`problem`), { final: true }) + await flushPromises() + + // Model: after the Effect's first ready, the failure is health. + expect(effect.disposed).toBe(false) + expect(sourceErrors).toEqual([]) + expect(trees.length).toBeGreaterThan(0) + expect(trees.at(-1)).toMatchObject({ + kind: `live-query`, + hasErrors: true, + sources: [ + { + alias: `row`, + isError: true, + error: { + phase: syncMode === `on-demand` ? `initial` : `sync`, + final: true, + }, + }, + ], + }) + } finally { + console.error = consoleError + await effect.dispose() + await source.cleanup() + } + }) +}) From 4694a4462204e460c9cf9ec10ea36b6f18a90f5a Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 17:38:27 -0600 Subject: [PATCH 006/106] test(db): extend the health oracle with order, identity, include, pooled, root-request, and Effect-gate lanes Every step now checks that status:change precedes health:change, that the last event shows the current tree, and that tree nodes keep identity exactly when their modeled facts are unchanged. Adds the include node with its instances roll-up, a pooled view over a nested live query, root-level request errors after ready, and the Effect gate across skipInitial, source mode, and failure before or after the gate. Red until the following fixes. Co-authored-by: Isaac --- .../db/tests/source-health-oracle.test.ts | 615 +++++++++++++----- 1 file changed, 460 insertions(+), 155 deletions(-) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 42be145cd3..5bfdc05fe5 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -21,7 +21,21 @@ * order, recursing through nested live queries. The tree always has the same * shape for a given query. Each node mirrors its Collection at the moment it * is read, and the live query emits `health:change` when a node below it - * changes, so a reader knows to read the tree again. + * changes, so a reader knows to read the tree again. When a status changes, + * `status:change` fires first and `health:change` follows at once, and the + * tree is current when either fires. A reader can compare trees with `===`: + * a node keeps its identity until something in its subtree changes, and a + * change replaces only the nodes on its path. + * + * An include's source node has `kind: 'include'` and rolls up the include's + * per-row child Collections as `instances: { total, inError }`. A pooled + * live query shows its source's gate and the same tree a live-query + * Collection over that source would have, so its tree also reaches through + * a nested live query. + * + * An IndexedDB connection closure is not health: it ends read and admission + * authority, so the Collection moves to `error` even after ready. The + * IndexedDB oracles own that case. * * A live query's own gate follows its sources: it becomes ready when every * source is ready, and a source that fails before the query's first ready @@ -32,9 +46,11 @@ * ## The model * * The model keeps, for each source, its gate and its health problem (none, - * non-final, or final). It derives each live query's gate from its sources, - * using only the rules above, and builds the expected tree from those facts. - * It does not read production state. + * non-final, or final), with a serial for each reported problem. It derives + * each live query's gate from its sources, using only the rules above, and + * the include's parent rows from whether `B` synced its row while the + * attempt was running. It builds the expected tree from those facts. It does + * not read production state. * * ## The history grammar * @@ -44,34 +60,44 @@ * live query and every source, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw * for `markReady()` after `markError()`, and for a health report before the - * first ready. Five shapes cross the grammar: a direct source, a join, a - * self-join of one Collection under two aliases, a nested live query, and an - * include child. + * first ready. Six shapes cross the grammar: a direct source, a join, a + * self-join of one Collection under two aliases, a nested live query, an + * include child that materializes a Collection per parent row, and a pooled + * live query over a nested live query. * * ## The comparison * - * After every step the driver compares, with the model: the live query's - * status, its row count while ready, the full projected error tree, and the - * thrown error class. When the expected tree changed, it also checks that the - * live query emitted `health:change`. A request lane checks that a lazy load - * that fails after the first ready is a request error on the live query's - * node, which clears when that demand settles or is released. + * After every step the driver compares, with the model: the query's status, + * its row count while ready, the full projected error tree, and the thrown + * error class. It also checks identity: reading twice gives the same tree, + * and between steps a node is the same object exactly when its expected + * facts, including problem serials, did not change. At every event it checks + * that no `health:change` shows a status that `status:change` has not + * announced, and that each `status:change` is followed at once by + * `health:change`. When the expected tree changed, the last event must show + * the current tree. Request lanes check that a lazy load or a root source's + * load that fails after the first ready is a request error on the query's + * node, and that it clears. An Effect lane checks the Effect's gate. * * ## Limits * * Sources are eager, so a live query cannot become ready while a source is * still loading. The on-demand case, where a source can fail its initial sync - * after the live query is ready, is not generated here. The include node is - * one node for the include's source Collection; per-row child Collections are - * not nodes. Pooled live queries and Effects are outside this owner. + * after the live query is ready, is not generated here. Per-row child + * Collections are rolled up, not nodes, and no grammar step fails one, so + * `inError` is always 0 here; an instance change emits no `health:change` of + * its own. Pooled histories do not restart: a pooled view ends for good when + * its source is cleaned up. The ordering check reads the observed query + * only, not its sources. */ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createEffect } from '../src/query/effect.js' +import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' +import { Query } from '../src/query/builder/index.js' import { eq } from '../src/query/builder/functions.js' -import { toArray } from '../src/query/index.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import { oraclePropertyOptions } from './oracle-config.js' import { flushPromises } from './utils.js' @@ -86,7 +112,8 @@ import type { type Row = { id: number; parentId: number } type SyncParams = Parameters[`sync`]>[0] type SourceName = `A` | `B` -type Shape = `direct` | `join` | `self-join` | `nested` | `include` +type Shape = + `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` type Step = | { kind: `ready` | `fail` | `report` | `reportFinal` | `healthy` @@ -99,12 +126,17 @@ type Step = // --------------------------------------------------------------------------- type Gate = `loading` | `ready` | `error` -type Problem = null | { final: boolean } +// `serial` names one reported problem. A new report is a new problem even +// when it has the same finality, so the node that shows it is a new node. +type Problem = null | { final: boolean; serial: number } type SourceModel = { gate: Gate; problem: Problem } type Model = { sources: Record query: Gate inner: Gate + serial: number + // Parent rows the include query's attempt has received from `B`. + parentRows: number } const shapeSources: Record> = { @@ -113,6 +145,7 @@ const shapeSources: Record> = { 'self-join': [`A`], nested: [`A`, `B`], include: [`A`, `B`], + 'pooled-nested': [`A`], } function freshModel(): Model { @@ -121,6 +154,8 @@ function freshModel(): Model { sources: { A: source(), B: source() }, query: `loading`, inner: `loading`, + serial: 0, + parentRows: 0, } } @@ -139,7 +174,20 @@ function deriveQueries(shape: Shape, model: Model): void { if (shape === `nested`) { model.inner = deriveGate(model.inner, gates([`A`])) model.query = deriveGate(model.query, [model.inner, model.sources.B.gate]) + } else if (shape === `pooled-nested`) { + // A pooled view has no gate of its own: it shows its source's. + model.inner = deriveGate(model.inner, gates([`A`])) + model.query = model.inner } else { + // A running attempt receives `B`'s row once `B` syncs it; a failed + // attempt receives nothing more. + if ( + shape === `include` && + model.query !== `error` && + model.sources.B.gate === `ready` + ) { + model.parentRows = 1 + } model.query = deriveGate(model.query, gates(shapeSources[shape])) } } @@ -161,14 +209,18 @@ function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { break case `fail`: // Before the first ready the attempt fails; after it, a final problem. - if (source.gate === `ready`) source.problem = { final: true } - else source.gate = `error` + if (source.gate !== `ready`) source.gate = `error` + else if (!source.problem?.final) + source.problem = { final: true, serial: ++model.serial } break case `report`: case `reportFinal`: if (source.gate !== `ready`) outcome = `throws` else if (!source.problem?.final) - source.problem = { final: step.kind === `reportFinal` } + source.problem = { + final: step.kind === `reportFinal`, + serial: ++model.serial, + } break case `healthy`: if (source.gate !== `ready`) outcome = `throws` @@ -181,24 +233,36 @@ function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { type ExpectedNode = { alias: string - kind: `source` | `live-query` + kind: `source` | `live-query` | `include` status: Gate isError: boolean hasErrors: boolean - error: null | { phase: `initial` | `sync`; final: boolean } + error: null | { phase: `initial` | `sync`; final: boolean; serial?: number } + instances?: { total: number; inError: number } sources: Array } -function sourceNode(alias: string, source: SourceModel): ExpectedNode { +// `identity` adds each problem's serial, which the public tree does not show +// but which decides whether a node is new. +function sourceNode( + alias: string, + source: SourceModel, + identity: boolean, + kind: ExpectedNode[`kind`] = `source`, +): ExpectedNode { const error = source.gate === `error` ? { phase: `initial` as const, final: true } : source.problem - ? { phase: `sync` as const, final: source.problem.final } + ? { + phase: `sync` as const, + final: source.problem.final, + ...(identity ? { serial: source.problem.serial } : {}), + } : null return { alias, - kind: `source`, + kind, status: source.gate, isError: error !== null, hasErrors: error !== null, @@ -225,30 +289,46 @@ function queryNode( } } -function expectedTree(shape: Shape, model: Model): ExpectedNode { +function expectedTree( + shape: Shape, + model: Model, + identity = false, +): ExpectedNode { const { A, B } = model.sources + const source = (alias: string, name: SourceModel) => + sourceNode(alias, name, identity) switch (shape) { case `direct`: - return queryNode(`root`, model.query, [sourceNode(`a`, A)]) + return queryNode(`root`, model.query, [source(`a`, A)]) case `join`: - return queryNode(`root`, model.query, [ - sourceNode(`a`, A), - sourceNode(`b`, B), - ]) + return queryNode(`root`, model.query, [source(`a`, A), source(`b`, B)]) case `self-join`: return queryNode(`root`, model.query, [ - sourceNode(`first`, A), - sourceNode(`second`, A), + source(`first`, A), + source(`second`, A), ]) case `nested`: return queryNode(`root`, model.query, [ - queryNode(`inner`, model.inner, [sourceNode(`a`, A)]), - sourceNode(`b`, B), + queryNode(`inner`, model.inner, [source(`a`, A)]), + source(`b`, B), ]) case `include`: + // The include node rolls up the per-row child Collections: one per + // parent row the attempt received. They stay until a restart, even + // when the attempt fails later. No grammar step fails a child + // Collection, so none is in error. return queryNode(`root`, model.query, [ - sourceNode(`b`, B), - sourceNode(`a`, A), + source(`b`, B), + { + ...sourceNode(`a`, A, identity, `include`), + instances: { total: model.parentRows, inError: 0 }, + }, + ]) + case `pooled-nested`: + // The view's node shows its source's gate, and the source's subtree + // continues through the nested live query. + return queryNode(`root`, model.query, [ + queryNode(`inner`, model.inner, [source(`a`, A)]), ]) } } @@ -279,55 +359,98 @@ function createSource(name: SourceName) { } } +// The observed query and how the driver starts and measures it. A live-query +// Collection starts with a preload; a pooled view starts when it has a +// subscriber, as an observer would subscribe. +type Subject = { + query: Pick, `status` | `errors` | `on` | `cleanup`> + inner?: Collection + start: () => void + size: () => number +} + +function liveQuery(query: Collection, inner?: Collection) { + return { + query, + inner, + start: () => void query.preload().catch(() => undefined), + size: () => query.size, + } +} + function buildQuery( shape: Shape, A: Collection, B: Collection, -) { +): Subject { switch (shape) { case `direct`: - return { query: createLiveQueryCollection((q) => q.from({ a: A })) } + return liveQuery(createLiveQueryCollection((q) => q.from({ a: A }))) case `join`: - return { - query: createLiveQueryCollection((q) => + return liveQuery( + createLiveQueryCollection((q) => q.from({ a: A }).join({ b: B }, ({ a, b }) => eq(a.id, b.id)), ), - } + ) case `self-join`: - return { - query: createLiveQueryCollection((q) => + return liveQuery( + createLiveQueryCollection((q) => q .from({ first: A }) .join({ second: A }, ({ first, second }) => eq(first.id, second.id), ), ), - } + ) case `nested`: { const inner = createLiveQueryCollection((q) => q.from({ a: A })) - return { - inner, - query: createLiveQueryCollection((q) => + return liveQuery( + createLiveQueryCollection((q) => q .from({ inner }) .join({ b: B }, ({ inner: row, b }) => eq(row.id, b.id)), ), - } + inner, + ) } case `include`: - return { - query: createLiveQueryCollection((q) => + // A plain subquery materializes one child Collection per parent row. + return liveQuery( + createLiveQueryCollection((q) => q.from({ b: B }).select(({ b }) => ({ id: b.id, - items: toArray( - q.from({ a: A }).where(({ a }) => eq(a.parentId, b.id)), - ), + items: q.from({ a: A }).where(({ a }) => eq(a.parentId, b.id)), })), ), + ) + case `pooled-nested`: { + const inner = createLiveQueryCollection((q) => q.from({ a: A })) + const view = createPooledLiveQuery( + new Query() + .from({ inner }) + .where(({ inner: row }) => + eq(row.parentId, 1), + ) as unknown as Parameters[0], + ) as unknown as PooledView + let subscription: { unsubscribe: () => void } | undefined + return { + query: view, + inner, + start: () => { + subscription?.unsubscribe() + subscription = view.subscribeChanges(() => {}) + }, + size: () => [...view.entries()].length, } + } } } +type PooledView = Subject[`query`] & { + subscribeChanges: (listener: () => void) => { unsubscribe: () => void } + entries: () => Iterable +} + // The public tree, projected onto the fields the model owns. function project(node: ErrorTreeNode): ExpectedNode { return { @@ -342,42 +465,98 @@ function project(node: ErrorTreeNode): ExpectedNode { final: node.error.final, } : null, + ...(node.instances ? { instances: node.instances } : {}), sources: node.sources.map(project), } } +// A node whose expected facts did not change is the same object; a node whose +// facts changed is a new object, and so is every node above it. +function expectIdentity( + previous: ErrorTreeNode, + next: ErrorTreeNode, + before: ExpectedNode, + after: ExpectedNode, + label: string, +): void { + const changed = JSON.stringify(before) !== JSON.stringify(after) + expect(previous === next, `${label}: ${after.alias} identity`).toBe(!changed) + if (!changed) return + after.sources.forEach((source, index) => + expectIdentity( + previous.sources[index]!, + next.sources[index]!, + before.sources[index]!, + source, + label, + ), + ) +} + async function runHistory( shape: Shape, steps: ReadonlyArray, ): Promise { const sources = { A: createSource(`A`), B: createSource(`B`) } - const { query, inner } = buildQuery( + const { query, inner, start, size } = buildQuery( shape, sources.A.collection, sources.B.collection, ) const model = freshModel() - let events = 0 + // A pooled view ends for good when its source is cleaned up, and a later + // mount gets a new view, so its histories do not restart. + if (shape === `pooled-nested`) { + steps = steps.filter((step) => step.kind !== `restart`) + } + // Each event, with the status announced so far and the tree read then. + type Event = { + kind: `status` | `health` + announced: string + status: string + tree: ExpectedNode + } + let events: Array = [] + let announced: string = query.status let stop = () => {} // Cleanup without subscribers drops event listeners, so each attempt // registers its own. - const start = () => { + const begin = () => { stop() - stop = query.on(`health:change`, () => { - events++ - }) - void query.preload().catch(() => undefined) + announced = query.status + const record = (kind: Event[`kind`]) => { + if (kind === `status`) announced = query.status + const tree = query.errors + events.push({ + kind, + announced, + status: tree.status, + tree: project(tree), + }) + } + const stopStatus = query.on(`status:change`, () => record(`status`)) + const stopHealth = query.on(`health:change`, () => record(`health`)) + stop = () => { + stopStatus() + stopHealth() + } + start() } const consoleError = console.error console.error = () => {} try { - start() + expect( + query.errors, + `${shape}: the query exposes an error tree`, + ).toBeDefined() + begin() await flushPromises() + let previous = query.errors for (const [index, step] of steps.entries()) { const label = `step ${index} ${JSON.stringify(step)} on ${shape}` - const before = JSON.stringify(expectedTree(shape, model)) + const before = expectedTree(shape, model, true) const expected = applyModel(shape, model, step) - events = 0 + events = [] let thrown: unknown try { if (step.kind === `restart`) { @@ -387,7 +566,7 @@ async function runHistory( sources.A.collection.cleanup(), sources.B.collection.cleanup(), ]) - start() + begin() } else if (shapeSources[shape].includes(step.source)) { const params = sources[step.source].params if (step.kind === `ready`) { @@ -429,12 +608,43 @@ async function runHistory( model.query as CollectionStatus, ) if (model.query === `ready`) { - expect(query.size, `${label}: rows stay while ready`).toBe(1) + expect(size(), `${label}: rows stay while ready`).toBe(1) } const tree = expectedTree(shape, model) - expect(project(query.errors), `${label}: error tree`).toEqual(tree) - if (step.kind !== `restart` && JSON.stringify(tree) !== before) { - expect(events, `${label}: health:change relay`).toBeGreaterThan(0) + const next = query.errors + expect(project(next), `${label}: error tree`).toEqual(tree) + + // Identity: a reader can compare trees with `===`. Reading twice gives + // the same tree, and a step replaces exactly the nodes on the path to + // what changed. + expect(query.errors === next, `${label}: stable read`).toBe(true) + const after = expectedTree(shape, model, true) + if (step.kind !== `restart`) { + expectIdentity(previous, next, before, after, label) + } + previous = next + + // Order: `status:change` comes first and `health:change` follows it at + // once, so no `health:change` shows a status that `status:change` has + // not announced. The last event leaves a reader with the current tree. + if (step.kind !== `restart`) { + for (const [at, event] of events.entries()) { + expect(event.status, `${label}: event ${at} status announced`).toBe( + event.announced, + ) + if (event.kind === `status`) { + expect( + events[at + 1]?.kind, + `${label}: health follows status`, + ).toBe(`health`) + } + } + if (JSON.stringify(before) !== JSON.stringify(after)) { + expect( + events.at(-1)?.tree, + `${label}: last event is current`, + ).toEqual(tree) + } } } } finally { @@ -459,6 +669,7 @@ const shapes: ReadonlyArray = [ `self-join`, `nested`, `include`, + `pooled-nested`, ] const on = ( kind: Exclude, @@ -548,11 +759,13 @@ describe(`source failure and health oracle`, () => { // --------------------------------------------------------------------------- /** - * A live query's lazy source loads only the keys its rows reach. After the - * query's first ready, a load that fails is a request error on the query's - * own node: the source stays healthy and the query keeps its status. It - * clears when the same demand later settles, or when the query stops needing - * it. Before the first ready, the same failure fails the query's attempt. + * A live query's lazy source loads only the keys its rows reach, and an + * on-demand root source loads the window the query shows. After the query's + * first ready, a load that fails is a request error on the query's own node: + * the source stays healthy and the query keeps its status. A lazy demand's + * error clears when the same demand later settles, or when the query stops + * needing it; a root source's error clears when a later load of that source + * settles. Before the first ready, the same failure fails the query's attempt. */ describe(`request errors`, () => { function createRequestHistory() { @@ -607,6 +820,10 @@ describe(`request errors`, () => { run: (history: ReturnType) => Promise, ) { const history = createRequestHistory() + expect( + history.query.errors, + `the query exposes an error tree`, + ).toBeDefined() const consoleError = console.error console.error = () => {} try { @@ -682,100 +899,188 @@ describe(`request errors`, () => { })) }) -// --------------------------------------------------------------------------- -// Effect lane -// --------------------------------------------------------------------------- - -/** - * An Effect has the same two axes. A source failure before the Effect's first - * ready disposes it through `onSourceError`. After its first ready, a source - * problem does not dispose it: `onHealthChange` receives the error tree, with - * one node per source alias. An on-demand source can fail its own initial - * sync after the Effect is ready, because the Effect's readiness follows its - * settled loads; that failure is health too. - */ -describe(`effect health`, () => { - type EffectHistory = { - syncMode: `eager` | `on-demand` - failBeforeReady: boolean - failure: `markError` | `reportFinal` - } - const histories: Array = [ - { syncMode: `eager`, failBeforeReady: true, failure: `markError` }, - { syncMode: `eager`, failBeforeReady: false, failure: `markError` }, - { syncMode: `eager`, failBeforeReady: false, failure: `reportFinal` }, - { syncMode: `on-demand`, failBeforeReady: false, failure: `markError` }, - ] - - it.each(histories)(`%j`, async ({ syncMode, failBeforeReady, failure }) => { - let params!: SyncParams +describe(`root request errors`, () => { + it(`a failed root load after ready is a request error that clears when a later load settles`, async () => { + const outcomes: Array<`ok` | `fail`> = [] const source = createCollection({ - id: `health-effect-${syncMode}-${Math.random().toString(36).slice(2)}`, + id: `health-root-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, - syncMode, + syncMode: `on-demand`, gcTime: 0, sync: { - sync: (next) => { - params = next - if (syncMode === `on-demand`) return { loadSubset: () => true } - return undefined + sync: (params) => { + params.markReady() + return { + loadSubset: () => + (outcomes.shift() ?? `ok`) === `ok` + ? Promise.resolve() + : Promise.reject(new Error(`root load failed`)), + } }, }, }) - const sourceErrors: Array = [] - const trees: Array = [] - const effect = createEffect({ - query: (q) => q.from({ row: source }), - onBatch: () => {}, - onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), - }) + const query = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.id) + .limit(2), + ) + expect(query.errors, `the query exposes an error tree`).toBeDefined() const consoleError = console.error console.error = () => {} try { - await flushPromises() - if (failBeforeReady) { - params.markError(new Error(`gave up`)) - await flushPromises() - // Model: the Effect never reached ready, so the failure ends it. - expect(effect.disposed).toBe(true) - expect(sourceErrors).toHaveLength(1) - expect(trees).toEqual([]) - return - } - if (syncMode === `eager`) { - params.begin() - params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) - params.commit() - params.markReady() - await flushPromises() - } - if (failure === `markError`) params.markError(new Error(`gave up`)) - else params.reportError(new Error(`problem`), { final: true }) - await flushPromises() + await query.preload() + expect(query.errors.isError).toBe(false) - // Model: after the Effect's first ready, the failure is health. - expect(effect.disposed).toBe(false) - expect(sourceErrors).toEqual([]) - expect(trees.length).toBeGreaterThan(0) - expect(trees.at(-1)).toMatchObject({ - kind: `live-query`, - hasErrors: true, - sources: [ - { - alias: `row`, - isError: true, - error: { - phase: syncMode === `on-demand` ? `initial` : `sync`, - final: true, - }, - }, - ], + outcomes.push(`fail`) + await Promise.resolve( + query.utils.setWindow({ offset: 4, limit: 2 }), + ).catch(() => undefined) + await flushPromises() + expect(query.status).toBe(`ready`) + expect(query.errors.error).toMatchObject({ + phase: `request`, + final: false, }) + expect(source.health.isError).toBe(false) + + await query.utils.setWindow({ offset: 8, limit: 2 }) + await flushPromises() + expect(query.errors.isError).toBe(false) } finally { console.error = consoleError - await effect.dispose() + await query.cleanup() await source.cleanup() } }) }) + +// --------------------------------------------------------------------------- +// Effect lane +// --------------------------------------------------------------------------- + +/** + * An Effect works the same as a query. Its gate is its initial load: every + * initial load has settled and every eager source is ready, whether or not it + * has `skipInitial`. `skipInitial` only decides whether the initial rows are + * delivered. A source failure before the gate disposes the Effect through + * `onSourceError`. After the gate, a source problem does not dispose it: + * `onHealthChange` receives the error tree, with one node per source alias. + * An on-demand source can fail its own initial sync after the gate, because + * the gate follows the Effect's settled loads; that failure is health too. + * + * The lane crosses `skipInitial`, the source mode, and a failure before or + * after the gate. Before the gate, an eager source has not called + * `markReady()`, and an on-demand source still has its first load pending. + */ +describe(`effect health`, () => { + type EffectHistory = { + skipInitial: boolean + syncMode: `eager` | `on-demand` + failBeforeGate: boolean + } + const histories: Array = [true, false].flatMap((skipInitial) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + [true, false].map((failBeforeGate) => ({ + skipInitial, + syncMode, + failBeforeGate, + })), + ), + ) + + it.each(histories)( + `%j`, + async ({ skipInitial, syncMode, failBeforeGate }) => { + let params!: SyncParams + let settleLoad = () => {} + const writeRow = () => { + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + } + const source = createCollection({ + id: `health-effect-${syncMode}-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode, + gcTime: 0, + sync: { + sync: (next) => { + params = next + if (syncMode === `eager`) return undefined + return { + loadSubset: () => + new Promise((resolve) => { + settleLoad = () => { + writeRow() + resolve() + } + }), + } + }, + }, + }) + const sourceErrors: Array = [] + const trees: Array = [] + const delivered: Array = [] + const effect = createEffect({ + query: (q) => q.from({ row: source }), + skipInitial, + onBatch: (events) => { + delivered.push(...events.map((event) => event.key)) + }, + onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), + }) + const consoleError = console.error + console.error = () => {} + try { + await flushPromises() + if (failBeforeGate) { + params.markError(new Error(`gave up`)) + await flushPromises() + // Model: the Effect never passed its gate, so the failure ends it. + expect(effect.disposed).toBe(true) + expect(sourceErrors).toHaveLength(1) + expect(trees).toEqual([]) + return + } + // Pass the gate: the eager source becomes ready, or the on-demand + // source's first load settles. + if (syncMode === `eager`) { + writeRow() + params.markReady() + } else { + settleLoad() + } + await flushPromises() + // Model: only `skipInitial` withholds the initial rows. + expect(delivered).toEqual(skipInitial ? [] : [1]) + + params.markError(new Error(`gave up`)) + await flushPromises() + // Model: after the gate, the failure is health. + expect(effect.disposed).toBe(false) + expect(sourceErrors).toEqual([]) + expect(trees.at(-1)).toMatchObject({ + kind: `live-query`, + hasErrors: true, + sources: [ + { + alias: `row`, + isError: true, + error: { + phase: syncMode === `on-demand` ? `initial` : `sync`, + final: true, + }, + }, + ], + }) + } finally { + console.error = consoleError + await effect.dispose() + await source.cleanup() + } + }, + ) +}) From 585dfdce0d84ee9a9723eac06e4993e302d36c42 Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 17:38:30 -0600 Subject: [PATCH 007/106] feat(db): order health after status, keep error-tree identity, add include nodes and root request errors - setStatus emits health:change right after status:change; the live-query status handler no longer emits its own relay. - Error-tree nodes are cached per owner and alias, so a change replaces only its path. One errorTreeNode helper builds Collection, pooled, and Effect nodes. - Include sources have kind 'include' and roll up per-row child Collections as instances { total, inError }. - A root source's load that fails after ready is a request error, cleared when a later load of that source settles. - A pooled view shows its source's gate error; the first initial error stays final for its attempt; the observer publishes a health change only when the tree changed. - ARCHITECTURE laws 16 and 17 and the Effect gate wording are updated. Co-authored-by: Isaac --- packages/db/src/collection/error-tree.ts | 80 +++++++++++++++++-- packages/db/src/collection/lifecycle.ts | 21 +++-- packages/db/src/live-query-observer.ts | 19 +++-- packages/db/src/query/ir.ts | 10 ++- packages/db/src/query/live/ARCHITECTURE.md | 40 +++++++--- .../src/query/live/bucket-facade-adapter.ts | 10 +++ .../query/live/collection-config-builder.ts | 80 ++++++++++++------- .../src/query/live/collection-subscriber.ts | 10 ++- .../src/query/live/materialized-pipeline.ts | 5 ++ packages/db/src/query/pooled-live-query.ts | 40 +++++----- packages/db/src/types.ts | 5 +- packages/db/tests/facade-retention.probe.ts | 10 ++- .../tests/query/bucket-facade-adapter.test.ts | 52 ++++++++++-- ...et-facade-rollback-oracle.property.test.ts | 3 + 14 files changed, 296 insertions(+), 89 deletions(-) diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index f0118afecd..4b3cc16358 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -5,8 +5,18 @@ import type { CollectionImpl } from './index.js' export interface ErrorTreeSource { readonly alias: string readonly collection: CollectionImpl + readonly kind?: ErrorTreeNode[`kind`] + /** An include's per-row child Collections, for its `instances` roll-up. */ + readonly instances?: () => ReadonlyArray< + CollectionImpl + > } +// One cached node per Collection and alias. A node keeps its identity until +// its own fields or one of its children change, so a change replaces only the +// nodes on its path and readers can compare nodes with `===`. +const nodeCache = new WeakMap>() + /** * Build the error tree for a Collection. A base Collection is one node. A live * query is a node with one child per source alias, in query order, and each @@ -16,22 +26,82 @@ export interface ErrorTreeSource { export function buildErrorTree( collection: CollectionImpl, alias: string = `root`, + kind?: ErrorTreeNode[`kind`], + instances?: ErrorTreeSource[`instances`], ): ErrorTreeNode { const sources = collection._errorTreeSources?.() ?? [] const lifecycle = collection._lifecycle const error: CollectionErrorInfo | null = lifecycle.gateProblem ?? lifecycle.health.error const children = sources.map((source) => - buildErrorTree(source.collection, source.alias), + buildErrorTree( + source.collection, + source.alias, + source.kind, + source.instances, + ), ) - return { + return errorTreeNode(collection, { alias, collectionId: collection.id, - kind: collection._errorTreeSources ? `live-query` : `source`, + kind: kind ?? (collection._errorTreeSources ? `live-query` : `source`), status: collection.status, - isError: error !== null, - hasErrors: error !== null || children.some((child) => child.hasErrors), error, + ...(instances ? { instances: countInstances(instances()) } : {}), sources: children, + }) +} + +/** + * A node from its own facts and its children. The node is in error when it + * has a problem, and has errors when it or anything below it does. + */ +export function errorTreeNode( + owner: object, + fields: Omit, +): ErrorTreeNode { + const { error, instances, sources } = fields + return cachedNode(owner, { + ...fields, + isError: error !== null, + hasErrors: + error !== null || + (instances?.inError ?? 0) > 0 || + sources.some((child) => child.hasErrors), + }) +} + +function countInstances( + collections: ReadonlyArray>, +): { total: number; inError: number } { + let inError = 0 + for (const instance of collections) { + if (instance.status === `error` || instance.health.isError) inError++ } + return { total: collections.length, inError } +} + +/** Return the cached node for this Collection and alias if nothing changed. */ +function cachedNode(owner: object, next: ErrorTreeNode): ErrorTreeNode { + let nodes = nodeCache.get(owner) + if (!nodes) nodeCache.set(owner, (nodes = new Map())) + const previous = nodes.get(next.alias) + if (previous && sameNode(previous, next)) return previous + nodes.set(next.alias, next) + return next +} + +function sameNode(a: ErrorTreeNode, b: ErrorTreeNode): boolean { + return ( + a.collectionId === b.collectionId && + a.kind === b.kind && + a.status === b.status && + a.isError === b.isError && + a.hasErrors === b.hasErrors && + a.error === b.error && + a.instances?.total === b.instances?.total && + a.instances?.inError === b.instances?.inError && + a.sources.length === b.sources.length && + a.sources.every((source, index) => source === b.sources[index]) + ) } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index ce8484ed4c..a316353950 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -61,6 +61,9 @@ export class CollectionLifecycleManager< // live query's request failures are separate, so settling a request cannot // clear a source problem. private sourceProblem: CollectionErrorInfo | null = null + // Error tree detail for the initial gate error, kept so readers see the + // same object until the attempt ends. + private initialProblem: CollectionErrorInfo | null = null private requestProblem: CollectionErrorInfo | null = null private cleanupConfig: () => void private statusRevision = 0 @@ -151,11 +154,11 @@ export class CollectionLifecycleManager< beforeEmit?.() // Emit event - this.events.emitStatusChange( - newStatus, - previousStatus, - () => this.statusRevision === revision, - ) + const isCurrent = () => this.statusRevision === revision + this.events.emitStatusChange(newStatus, previousStatus, isCurrent) + // The status is part of the error tree. status:change fires first, and + // the tree is already current when either event fires. + if (isCurrent()) this.events.emitHealthChange() } /** @@ -272,8 +275,11 @@ export class CollectionLifecycleManager< * unpublished rows stops being usable. */ public crash(error?: unknown): void { + // The first error is final for the attempt. + if (this.status === `error`) return this.validateStatusTransition(this.status, `error`) this.syncError = error + this.initialProblem = describeProblem(error, `initial`, true) this.setStatus(`error`) } @@ -284,9 +290,7 @@ export class CollectionLifecycleManager< /** The cause of the initial gate error, as error tree detail. */ public get gateProblem(): CollectionErrorInfo | null { - return this.status === `error` - ? describeProblem(this.syncError, `initial`, true) - : null + return this.status === `error` ? this.initialProblem : null } /** A source's report after its first ready. Status and rows do not change. */ @@ -570,6 +574,7 @@ export class CollectionLifecycleManager< // Health belongs to the discarded attempt. this.sourceProblem = null this.requestProblem = null + this.initialProblem = null // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. this.onFirstReadyCallbacks = [] diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index c4e6549c24..aec9e6f4af 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -760,13 +760,20 @@ class LiveQueryObserverImpl< // any status change publishes a synthetic notify so consumers re-read the // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a // detached attachment leaves nothing behind. - const statusUnsub = collection.on(`status:change`, ({ status }) => - notify(undefined, status), - ) // A health change below the query changes its error tree, not its rows. - const healthUnsub = collection.on(`health:change`, () => - notify(undefined, collection.status), - ) + // Each status change is followed by a health change; tree identity + // tells whether the health change has anything left to publish. + const errorsOf = () => (collection as { errors?: ErrorTreeNode }).errors + let publishedErrors = errorsOf() + const statusUnsub = collection.on(`status:change`, ({ status }) => { + publishedErrors = errorsOf() + notify(undefined, status) + }) + const healthUnsub = collection.on(`health:change`, () => { + if (errorsOf() === publishedErrors) return + publishedErrors = errorsOf() + notify(undefined, collection.status) + }) const subscribeLayoutChanges = ( collection as Collection & { _subscribeLayoutChanges?: (listener: () => void) => () => void diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index a1e9a67ea4..340b2acec0 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -332,15 +332,21 @@ export function isExpressionLike(value: unknown): boolean { } /** Returns each lexical Collection or descriptor source in a query tree once. */ -export function collectSourceRefs(query: QueryIR): Array { +export function collectSourceRefs( + query: QueryIR, + /** Receives the IDs of sources first reached through an include. */ + includeSourceIds?: Set, +): Array { const sources: Array = [] const seen = new Set() + let includeDepth = 0 const visitSource = (source: QueryIR[`from`]): void => { if (source.type === `collectionRef` || source.type === `descriptorRef`) { if (!seen.has(source.sourceId)) { seen.add(source.sourceId) sources.push(source) + if (includeDepth > 0) includeSourceIds?.add(source.sourceId) } } else if (source.type === `queryRef`) { visitQuery(source.query) @@ -353,7 +359,9 @@ export function collectSourceRefs(query: QueryIR): Array { const visitSelectValue = (value: any): void => { if (value instanceof IncludesSubquery) { + includeDepth++ visitQuery(value.query) + includeDepth-- } else if (value instanceof ConditionalSelect) { value.branches.forEach((branch) => visitSelectValue(branch.value)) if (value.defaultValue !== undefined) { diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index df6a42a1d6..d95c131da6 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1333,12 +1333,14 @@ Every consumer of initial readiness uses the same rule: `sourcePermitsInitialReadiness()` in `utils.ts` holds it. Framework adapters derive `isReady` and Suspense release from live-query readiness, so they release together. A read that waits for readiness, such as `queryOnce`, -resolves at the same point. An Effect with `skipInitial` ends its initial -suppression there too. Its initial subset loads and lazy demand hold -suppression until they settle. An obsolete or aborted initial load releases its -hold, and a truncate replacement takes the hold over. Neither can keep -suppression open or close it for good. A real load failure disposes the -Effect. +resolves at the same point. Every Effect passes its gate there too, with or +without `skipInitial`: its initial subset loads and lazy demand hold the gate +until they settle. `skipInitial` only decides whether the initial rows are +delivered, so with it the gate also ends initial suppression. An obsolete or +aborted initial load releases its hold, and a truncate replacement takes the +hold over. Neither can keep the gate open or close it for good. A real load +failure before the gate disposes the Effect; after the gate, a source problem +is health, delivered through `onHealthChange`. Two limits remain. Before an on-demand source completes a full sync, a ready query can hold rows that the source does not yet update. The source contract, @@ -1574,14 +1576,26 @@ create recursive Collection machinery. source that fails fails the query's attempt, for every shape. After its first ready, a source's later problem is health metadata: the query keeps its status and rows, and its error tree reports the problem. A lazy load - that fails after the first ready is a request error on the query's node, - cleared when that demand settles or is released. Query failures and - contract violations, such as a graph failure or a source cleaned up under - the query, still end the query. + or a root source's load that fails after the first ready is a request + error on the query's node. A lazy demand's error clears when that demand + settles or is released; a root source's error clears when a later load of + that source settles. Query failures and contract violations, such as a + graph failure or a source cleaned up under the query, still end the + query. So does an IndexedDB connection closure: it ends read and + admission authority, which no health report can restore. An Effect has + the same two axes, with its gate at its settled initial loads. 17. **Error tree:** a live query's `errors` has one node for itself and one per - source alias in query order, recursing through nested live queries. The - shape is fixed by the query. Each node mirrors its Collection when read, - and the query emits `health:change` whenever a node below it changes. + source alias in query order, recursing through nested live queries. An + include's source has `kind: 'include'`; when the include materializes a + child Collection per parent row, its node rolls those up as + `instances: { total, inError }`. A pooled live query's tree is the tree a + live-query Collection over its source would have, so it also reaches + deeper levels. The shape is fixed by the query. Each node mirrors its + Collection when read, and the query emits `health:change` whenever a node + below it changes. When a Collection's status changes, its `status:change` + fires first and its `health:change` follows at once; the tree is current + when either fires. A node keeps its object identity until something in its + subtree changes, and a change replaces only the nodes on its path. ## Glossary diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index 02a60b071f..dff3e14f41 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -218,6 +218,16 @@ export class BucketFacadeAdapter { return this.resolveValue(value) as T } + /** The live per-row Collections of the includes that read this alias. */ + instancesFor(alias: string): Array> { + return this.compilations + .filter((compilation) => compilation.aliases.includes(alias)) + .flatMap((compilation) => [ + ...(this.entries.get(compilation.edgeId)?.values() ?? []), + ]) + .map((entry) => entry.collection) + } + cleanup(): void { this.cleanedUp = true for (const byBucket of this.entries.values()) { diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 4a4c81d9c7..0c9ee9493c 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -13,6 +13,7 @@ import { runAllCallbacks } from '../../utils/callbacks.js' import { normalizeError } from '../../utils/error.js' import { createSourceRecord } from '../../utils/source-record.js' import { codedMessage, devBuild } from '../../error-message.js' +import { collectSourceRefs } from '../ir.js' import { CollectionSubscriber } from './collection-subscriber.js' import { getCollectionBuilder } from './collection-registry.js' import { LIVE_QUERY_INTERNAL } from './internal.js' @@ -116,7 +117,7 @@ export class CollectionConfigBuilder< private isInErrorState = false private fatalQueryError = false // Lazy demand that failed after the first ready, as request errors. - private readonly failedDemands = new Set() + private readonly failedRequests = new Set() private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions @@ -153,6 +154,8 @@ export class CollectionConfigBuilder< Map> | undefined private bucketFacadesCache: ReturnType[`facades`] | undefined + private bucketFacades: BucketFacadeAdapter | undefined + private readonly includeSourceIds = new Set() // Map of opaque source ID to subscription readonly subscriptions = createSourceRecord() @@ -197,6 +200,7 @@ export class CollectionConfigBuilder< this.settledWindow = this.initialWindow this.collections = extractCollectionsFromQuery(this.query) this.collectionSources = extractCollectionSources(this.query) + collectSourceRefs(this.query, this.includeSourceIds) // Create compare function for ordering if the query has orderBy if (this.query.orderBy && this.query.orderBy.length > 0) { @@ -427,17 +431,27 @@ export class CollectionConfigBuilder< /** The source aliases of this query, in query order, for its error tree. */ errorTreeSources(): ReadonlyArray { - return this.collectionSources.map(({ alias, collection }) => ({ - alias, - collection, - })) + return this.collectionSources.map(({ sourceId, alias, collection }) => { + if (!this.includeSourceIds.has(sourceId)) return { alias, collection } + const instanced = this.bucketFacadesCache?.some((compilation) => + compilation.aliases.includes(alias), + ) + return { + alias, + collection, + kind: `include` as const, + ...(instanced + ? { instances: () => this.bucketFacades?.instancesFor(alias) ?? [] } + : {}), + } + }) } settleDemand(planId: string, generation: number): void { const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation || demand.settled) return demand.settled = true - this.clearFailedDemand(planId) + this.clearFailedRequest(planId) try { this.maybeRunGraphFn?.() demand.participant?.resolve() @@ -461,14 +475,7 @@ export class CollectionConfigBuilder< if (!demand || demand.generation !== generation) return const normalized = this.recordSubsetError(error) demand.participant?.reject(normalized) - // After the first ready, a failed load is a request error on this query's - // node. It clears when the demand settles later or is released. - const lifecycle = this.liveQueryCollection?._lifecycle - if (lifecycle?.status === `ready`) { - this.failedDemands.add(planId) - lifecycle.reportRequestError(normalized) - return - } + if (this.reportFailedRequest(planId, normalized)) return this.transitionToError( devBuild() && process.env.NODE_ENV !== `production` ? `Subset demand '${planId}' failed: ${normalized.message}` @@ -567,13 +574,33 @@ export class CollectionConfigBuilder< retireDemand(planId: string): void { const demand = this.activeDemands.get(planId) this.activeDemands.delete(planId) - this.clearFailedDemand(planId) + this.clearFailedRequest(planId) demand?.participant?.resolve() } - private clearFailedDemand(planId: string): void { - if (!this.failedDemands.delete(planId)) return - if (this.failedDemands.size === 0) + /** A root load of an eager source failed. */ + failRootLoad(sourceId: string, error: Error): void { + this.reportFailedRequest(`root:${sourceId}`, error) + } + + /** A later root load of the source succeeded. */ + settleRootLoad(sourceId: string): void { + this.clearFailedRequest(`root:${sourceId}`) + } + + // After the first ready, a failed load is a request error on this query's + // node. It clears when every failed request settles later or is released. + private reportFailedRequest(key: string, error: Error): boolean { + const lifecycle = this.liveQueryCollection?._lifecycle + if (lifecycle?.status !== `ready`) return false + this.failedRequests.add(key) + lifecycle.reportRequestError(error) + return true + } + + private clearFailedRequest(key: string): void { + if (!this.failedRequests.delete(key)) return + if (this.failedRequests.size === 0) this.liveQueryCollection?._lifecycle.clearRequestError() } @@ -752,7 +779,7 @@ export class CollectionConfigBuilder< // Reset error state from any previous sync run so a restarted sync can become ready again. this.isInErrorState = false this.fatalQueryError = false - this.failedDemands.clear() + this.failedRequests.clear() this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config @@ -835,7 +862,7 @@ export class CollectionConfigBuilder< this.settledWindow = this.initialWindow this.isInErrorState = false this.fatalQueryError = false - this.failedDemands.clear() + this.failedRequests.clear() // Reset caches so a fresh graph/pipeline is compiled on next start // This avoids reusing a finalized D2 graph across GC restarts @@ -954,6 +981,7 @@ export class CollectionConfigBuilder< syncState.messagesCount += count }) : undefined + this.bucketFacades = bucketFacades if (bucketFacades) { syncState.unsubscribeCallbacks.add(() => bucketFacades.cleanup()) } @@ -1135,9 +1163,6 @@ export class CollectionConfigBuilder< ) { const { status } = event - // Every source status appears in this query's error tree. - this.emitErrorTreeChange() - // A source's initial error is final for its attempt. Before this query's // first ready it is this query's gate error, for every shape. After it, // the source node shows the error and this query keeps its rows. @@ -1239,11 +1264,6 @@ export class CollectionConfigBuilder< this.liveQueryCollection?._lifecycle.crash(error ?? new Error(message)) } - /** The error tree changed below this query; dependents read it again. */ - private emitErrorTreeChange(): void { - this.liveQueryCollection?._emitHealthChange() - } - private allRequiredSourcesReady() { return this.collectionSources.every(({ collection }) => sourcePermitsInitialReadiness(collection), @@ -1291,7 +1311,9 @@ export class CollectionConfigBuilder< }) // Relay health changes so this query's error tree stays current. syncState.unsubscribeCallbacks.add( - collection.on(`health:change`, () => this.emitErrorTreeChange()), + collection.on(`health:change`, () => + this.liveQueryCollection?._emitHealthChange(), + ), ) let cleanupStartError: Error | undefined const cleanupStartUnsubscribe = collection._onCleanupStart(() => { diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index b1e64861c9..26d0796604 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -75,6 +75,11 @@ export class CollectionSubscriber< // live query collection, avoiding the multi-hop deferred promise chain that // can break under microtask timing (e.g., queueMicrotask in TanStack Query). const trackLoadResult = (result: LoadSubsetRequestResult) => { + // A later successful load clears this source's request error. + void Promise.resolve(result).then( + () => this.collectionConfigBuilder.settleRootLoad(this.sourceId), + () => {}, + ) if (result instanceof Promise) { // Defer the tracked rejection by one microtask so the subscription's // error event can put an initial live query in error before loading @@ -106,7 +111,7 @@ export class CollectionSubscriber< } } const onLoadSubsetError = (event: SubscriptionLoadSubsetErrorEvent) => { - this.collectionConfigBuilder.recordSubsetError( + const error = this.collectionConfigBuilder.recordSubsetError( event.error, // Lazy demand owns its fatal-error path. For eager sources, one // successful page does not finish initial ordered refinement. @@ -114,6 +119,9 @@ export class CollectionSubscriber< this.collectionConfigBuilder.liveQueryCollection?.status === `loading`, ) + if (!this.collectionConfigBuilder.isLazySource(this.sourceId)) { + this.collectionConfigBuilder.failRootLoad(this.sourceId, error) + } } // Create subscription with onStatusChange - listener is registered before any async work diff --git a/packages/db/src/query/live/materialized-pipeline.ts b/packages/db/src/query/live/materialized-pipeline.ts index 455fa391eb..db8852c3bf 100644 --- a/packages/db/src/query/live/materialized-pipeline.ts +++ b/packages/db/src/query/live/materialized-pipeline.ts @@ -55,6 +55,8 @@ export type BucketRow = readonly [ export type BucketFacadeCompilation = { edgeId: string + /** The include's FROM and JOIN aliases, whose error-tree nodes roll up its instances. */ + aliases: Array rows: IStreamBuilder<[string, BucketRow]> activeBuckets: IStreamBuilder<[string, true]> hasOrderBy: boolean @@ -165,6 +167,9 @@ function materializeRelation( ) as IStreamBuilder<[string, BucketRow]> facades.push({ edgeId, + aliases: Object.keys( + include.childCompilationResult.aliasToCollectionId, + ), rows: activeBucketRows, activeBuckets, hasOrderBy: include.hasOrderBy, diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 78e9722c8a..6995921241 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -5,7 +5,7 @@ import { UNSUBSCRIBED_GC_FLOOR_MS, describeProblem, } from '../collection/lifecycle.js' -import { buildErrorTree } from '../collection/error-tree.js' +import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' import { makeComparator } from '../utils/comparison.js' import { isVirtualPropName } from '../virtual-props.js' import { getPersistedReadinessSource } from '../persisted-readiness.js' @@ -20,6 +20,7 @@ import type { Collection, CollectionImpl } from '../collection/index.js' import type { CollectionSubscription } from '../collection/subscription.js' import type { ChangeMessage, + CollectionErrorInfo, CollectionStatus, ErrorTreeNode, } from '../types.js' @@ -512,33 +513,34 @@ class PooledLiveQuery { return this.partition.terminated ? `error` : this.source.status } + private endedProblem: CollectionErrorInfo | undefined + /** The same error tree a live-query Collection over this source exposes. */ get errors(): ErrorTreeNode { const source = buildErrorTree(this.source, this.alias) - // A terminated partition ended this view, for example when its source - // was cleaned up under it. - const error = this.partition.terminated - ? (this.source._lifecycle.gateProblem ?? - describeProblem( - new Error( - devBuild() && process.env.NODE_ENV !== `production` - ? `The pooled live query's source ended` - : codedMessage(244), - ), - `initial`, - true, - )) - : null - return { + // The view shows its source's gate. A terminated partition also ended + // this view, for example when its source was cleaned up under it. + const error = + this.source._lifecycle.gateProblem ?? + (this.partition.terminated + ? (this.endedProblem ??= describeProblem( + new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `The pooled live query's source ended` + : codedMessage(244), + ), + `initial`, + true, + )) + : null) + return errorTreeNode(this, { alias: `root`, collectionId: this.source.id, kind: `live-query`, status: this.status, - isError: error !== null, - hasErrors: error !== null || source.hasErrors, error, sources: [source], - } + }) } // Read by key: the partition drops a group nobody watches once it empties. diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 4d0a27ab3f..0577a11a53 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -788,7 +788,8 @@ export interface ErrorTreeNode { /** The query alias, or `root` for the Collection the tree was read from. */ readonly alias: string readonly collectionId: string - readonly kind: `source` | `live-query` + /** `include` is an include's source; its rows feed per-row child Collections. */ + readonly kind: `source` | `live-query` | `include` /** The node's initial-load gate. */ readonly status: CollectionStatus /** This node has a problem now: a gate error, a health error, or a request error. */ @@ -796,6 +797,8 @@ export interface ErrorTreeNode { /** This node or any node below it has a problem. */ readonly hasErrors: boolean readonly error: CollectionErrorInfo | null + /** For an include node: its per-row child Collections, and how many have a problem. */ + readonly instances?: { readonly total: number; readonly inError: number } readonly sources: ReadonlyArray } diff --git a/packages/db/tests/facade-retention.probe.ts b/packages/db/tests/facade-retention.probe.ts index 05d4daba78..c1a8f66f8a 100644 --- a/packages/db/tests/facade-retention.probe.ts +++ b/packages/db/tests/facade-retention.probe.ts @@ -24,7 +24,15 @@ function capture( const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `retention-probe`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: false }], + [ + { + edgeId: `children`, + aliases: [], + rows, + activeBuckets, + hasOrderBy: false, + }, + ], () => {}, ) graph.finalize() diff --git a/packages/db/tests/query/bucket-facade-adapter.test.ts b/packages/db/tests/query/bucket-facade-adapter.test.ts index 5c1564869e..5e25ab05c2 100644 --- a/packages/db/tests/query/bucket-facade-adapter.test.ts +++ b/packages/db/tests/query/bucket-facade-adapter.test.ts @@ -67,7 +67,15 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `public-membership`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: false }], + [ + { + edgeId: `children`, + aliases: [], + rows, + activeBuckets, + hasOrderBy: false, + }, + ], () => {}, ) graph.finalize() @@ -123,7 +131,15 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-read-work`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: ordered }], + [ + { + edgeId: `children`, + aliases: [], + rows, + activeBuckets, + hasOrderBy: ordered, + }, + ], () => {}, ) graph.finalize() @@ -202,7 +218,15 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-order-parent`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: true }], + [ + { + edgeId: `children`, + aliases: [], + rows, + activeBuckets, + hasOrderBy: true, + }, + ], () => {}, ) graph.finalize() @@ -248,7 +272,15 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-rollback-parent`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: true }], + [ + { + edgeId: `children`, + aliases: [], + rows, + activeBuckets, + hasOrderBy: true, + }, + ], () => {}, ) graph.finalize() @@ -411,12 +443,14 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `first`, + aliases: [], rows: firstRows, activeBuckets: firstActiveBuckets, hasOrderBy: false, }, { edgeId: `second`, + aliases: [], rows: secondRows, activeBuckets: secondActiveBuckets, hasOrderBy: false, @@ -509,7 +543,15 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-index-rollback-parent`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: false }], + [ + { + edgeId: `children`, + aliases: [], + rows, + activeBuckets, + hasOrderBy: false, + }, + ], () => {}, ) graph.finalize() diff --git a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts index 8b9b0a00a3..d79bbd86a2 100644 --- a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts @@ -408,6 +408,7 @@ class Driver { [ { edgeId: `children`, + aliases: [], rows: this.rows, activeBuckets: this.activity, hasOrderBy: true, @@ -1427,12 +1428,14 @@ describe(`bucket facade rollback`, () => { [ { edgeId: `children`, + aliases: [], rows: childRows, activeBuckets: childActive, hasOrderBy: false, }, { edgeId: `parents`, + aliases: [], rows: parentRows, activeBuckets: parentActive, hasOrderBy: false, From 58ced4932aa0733d4f05599ace79c4ef1ff515df Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 17:38:34 -0600 Subject: [PATCH 008/106] fix(db): gate every Effect on its settled initial loads An Effect without skipInitial skipped initial-load tracking, so it passed its gate while an on-demand load was pending and treated a failure then as health instead of disposing. skipInitial now only decides delivery. Co-authored-by: Isaac --- packages/db/src/query/effect.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 09a85c1dc1..1cff153789 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -5,7 +5,7 @@ import { runAllCallbacks } from '../utils/callbacks.js' import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' -import { buildErrorTree } from '../collection/error-tree.js' +import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -469,8 +469,9 @@ class EffectPipelineRunner { // skipInitial state private readonly skipInitial: boolean private initialLoadComplete = false - // Latched when the effect's initial load can complete, with or without - // skipInitial. After it, a source problem is health, not disposal. + // The Effect's gate, as a live query's first ready: latched when every + // initial load has settled, with or without skipInitial. After it, a + // source problem is health, not disposal. private reachedReady = false private readonly onHealthChange: ((errors: ErrorTreeNode) => void) | undefined @@ -883,16 +884,12 @@ class EffectPipelineRunner { }) } - /** Keep `skipInitial` suppression open until a pending initial load settles. */ + /** Keep the Effect's gate, and `skipInitial` suppression, open until a pending initial load settles. */ private trackInitialLoad( result: true | Promise, replacementAfterFailure: () => Promise | undefined, ): void { - if ( - !(result instanceof Promise) || - this.initialLoadComplete || - this.disposed - ) + if (!(result instanceof Promise) || this.reachedReady || this.disposed) return this.pendingInitialLoads.add(result) const release = () => { @@ -1057,8 +1054,7 @@ class EffectPipelineRunner { ) } - /** Initial callbacks start only after every source served its initial data. */ - /** Latch the effect's first ready and report whether it has happened. */ + /** Latch the Effect's gate, its first ready, and report whether it has passed. */ private latchReady(): boolean { if (!this.reachedReady && this.canCompleteInitialLoad()) this.reachedReady = true @@ -1067,19 +1063,16 @@ class EffectPipelineRunner { /** The error tree for this effect's sources, in query order. */ private errorTree(): ErrorTreeNode { - const sources = this.collectionSources.map(({ alias, collection }) => - buildErrorTree(collection, alias), - ) - return { + return errorTreeNode(this, { alias: `root`, collectionId: `effect`, kind: `live-query`, status: this.reachedReady ? `ready` : `loading`, - isError: false, - hasErrors: sources.some((node) => node.hasErrors), error: null, - sources, - } + sources: this.collectionSources.map(({ alias, collection }) => + buildErrorTree(collection, alias), + ), + }) } private reportHealth(): void { From f2a7b89b75543b5d55f885c641a523ccd07c67b8 Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 17:59:38 -0600 Subject: [PATCH 009/106] test(db): add Effect request-error and include-instance lanes to the health oracle Effect lane: skipInitial x parent source mode, with a lazy load that fails after the gate and a later load that succeeds. Include lane: a per-row child Collection that fails, its relay, and two includes under one alias. Red until the following fix. Co-authored-by: Isaac --- .../db/tests/source-health-oracle.test.ts | 215 +++++++++++++++++- 1 file changed, 211 insertions(+), 4 deletions(-) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 5bfdc05fe5..49fd3a629d 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -77,16 +77,19 @@ * `health:change`. When the expected tree changed, the last event must show * the current tree. Request lanes check that a lazy load or a root source's * load that fails after the first ready is a request error on the query's - * node, and that it clears. An Effect lane checks the Effect's gate. + * node, and that it clears. Effect lanes check the Effect's gate and its + * request errors after the gate. * * ## Limits * * Sources are eager, so a live query cannot become ready while a source is * still loading. The on-demand case, where a source can fail its initial sync * after the live query is ready, is not generated here. Per-row child - * Collections are rolled up, not nodes, and no grammar step fails one, so - * `inError` is always 0 here; an instance change emits no `health:change` of - * its own. Pooled histories do not restart: a pooled view ends for good when + * Collections are rolled up, not nodes, and no grammar step fails one; the + * include-instances lane fails one and checks the roll-up, the relay, and + * that two includes under one alias count separately. An Effect's request + * error clears on a later successful load of the same source; release of + * only the failed demand is not exercised. Pooled histories do not restart: a pooled view ends for good when * its source is cleaned up. The ordering check reads the observed query * only, not its sources. */ @@ -99,6 +102,7 @@ import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' import { Query } from '../src/query/builder/index.js' import { eq } from '../src/query/builder/functions.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' +import { BasicIndex } from '../src/indexes/basic-index.js' import { oraclePropertyOptions } from './oracle-config.js' import { flushPromises } from './utils.js' import type { Collection } from '../src/collection/index.js' @@ -1084,3 +1088,206 @@ describe(`effect health`, () => { }, ) }) + +/** + * A load that fails after the Effect's gate is a request error, as it is on + * a live query's node: the Effect is not disposed, `onHealthChange` receives + * a tree whose root node has a `request` error, and a later successful load + * of the same source clears it. The parents lead the Effect, and a lazy join + * loads the children each new parent row reaches. The lane crosses + * `skipInitial` with the parents' source mode. + */ +describe(`effect request errors`, () => { + type RequestHistory = { + skipInitial: boolean + parentMode: `eager` | `on-demand` + } + const histories: Array = [true, false].flatMap( + (skipInitial) => + ([`eager`, `on-demand`] as const).map((parentMode) => ({ + skipInitial, + parentMode, + })), + ) + + it.each(histories)(`%j`, async ({ skipInitial, parentMode }) => { + let parentParams!: SyncParams + const outcomes: Array<`ok` | `fail`> = [] + const parents = createCollection({ + id: `health-effect-parents-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: parentMode, + gcTime: 0, + sync: { + sync: (params) => { + parentParams = params + if (parentMode === `on-demand`) { + params.markReady() + return { loadSubset: () => Promise.resolve() } + } + return undefined + }, + }, + }) + const children = createCollection({ + id: `health-effect-children-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => + (outcomes.shift() ?? `ok`) === `ok` + ? Promise.resolve() + : Promise.reject(new Error(`child load failed`)), + } + }, + }, + }) + const writeParent = (id: number) => { + parentParams.begin() + parentParams.write({ type: `insert`, value: { id, parentId: id } }) + parentParams.commit() + } + const sourceErrors: Array = [] + const trees: Array = [] + const effect = createEffect({ + query: (q) => + q + .from({ parent: parents }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + skipInitial, + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), + }) + const consoleError = console.error + console.error = () => {} + try { + await flushPromises() + writeParent(1) + if (parentMode === `eager`) parentParams.markReady() + await flushPromises() + + // Model: past the gate, a failed load is a request error. + outcomes.push(`fail`) + writeParent(2) + await flushPromises() + expect(effect.disposed).toBe(false) + expect(sourceErrors).toEqual([]) + expect(trees.at(-1)?.error).toMatchObject({ + phase: `request`, + final: false, + }) + expect(trees.at(-1)?.hasErrors).toBe(true) + + // Model: a later successful load of the same source clears it. + outcomes.push(`ok`) + writeParent(3) + await flushPromises() + expect(effect.disposed).toBe(false) + expect(trees.at(-1)?.error).toBeNull() + expect(trees.at(-1)?.hasErrors).toBe(false) + } finally { + console.error = consoleError + await effect.dispose() + await parents.cleanup() + await children.cleanup() + } + }) +}) + +/** + * An include node rolls up the include's own per-row child Collections. A + * child Collection that fails, here because an index the reader created on + * it throws on a new row, counts in `inError` and makes the include node + * `hasErrors`; the live query emits `health:change` for it and keeps its own + * status. Two includes that read one Collection under the same alias are + * two nodes, and each counts only its own children. + */ +describe(`include instances`, () => { + it(`a failing child Collection counts in its own include only`, async () => { + const make = (name: string) => { + let params!: SyncParams + const collection = createCollection({ + id: `health-include-${name}-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (next) => { + params = next + }, + }, + }) + const write = (row: Row) => { + params.begin() + params.write({ type: `insert`, value: row }) + params.commit() + } + return { collection, write, ready: () => params.markReady() } + } + const A = make(`A`) + const B = make(`B`) + const query = createLiveQueryCollection((q) => + q.from({ b: B.collection }).select(({ b }) => ({ + id: b.id, + items: q + .from({ a: A.collection }) + .where(({ a }) => eq(a.parentId, b.id)), + same: q.from({ a: A.collection }).where(({ a }) => eq(a.id, b.id)), + })), + ) + const consoleError = console.error + console.error = () => {} + try { + const preload = query.preload() + B.write({ id: 1, parentId: 1 }) + B.write({ id: 2, parentId: 2 }) + B.ready() + A.write({ id: 1, parentId: 1 }) + A.ready() + await preload + const includes = () => query.errors.sources.slice(1) + expect(includes().map((node) => [node.kind, node.instances])).toEqual([ + [`include`, { total: 2, inError: 0 }], + [`include`, { total: 2, inError: 0 }], + ]) + + let events = 0 + const stop = query.on(`health:change`, () => { + events++ + }) + // One child Collection of `items` fails: its index throws on row 9. + class FailingIndex extends BasicIndex { + override add(key: number, item: any): void { + if (key === 9) throw new Error(`index failed`) + super.add(key, item) + } + } + const items = query.get(1)!.items + items.createIndex((row) => row.id, { indexType: FailingIndex }) + expect(() => A.write({ id: 9, parentId: 1 })).toThrow(`index failed`) + await flushPromises() + stop() + + expect(items.status).toBe(`error`) + expect(query.status).toBe(`ready`) + expect(events, `health:change relay`).toBeGreaterThan(0) + const [itemsNode, sameNode] = includes() + expect(itemsNode!.instances).toEqual({ total: 2, inError: 1 }) + expect(itemsNode!.hasErrors).toBe(true) + expect(sameNode!.instances).toEqual({ total: 2, inError: 0 }) + expect(sameNode!.hasErrors).toBe(false) + expect(query.errors.hasErrors).toBe(true) + } finally { + console.error = consoleError + await query.cleanup() + await A.collection.cleanup() + await B.collection.cleanup() + } + }) +}) From e8508df79015c53112162a67bae3d7f122691b5e Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 13 Oct 2026 17:59:43 -0600 Subject: [PATCH 010/106] fix(db): make post-gate Effect load failures request errors and key include instances by source - After an Effect's gate, a failed load is a request error on its tree, delivered through onHealthChange and cleared when a later load of that source settles. Before the gate, onSourceError still disposes. Ordered initial loads now hold the gate too, and a rejected initial load releases its hold after the error event. - Include instances are keyed by the include's own source IDs, and tree nodes are cached per source, so two includes under one alias count separately. A per-row child Collection's health change is relayed. - The subset-error matrix and Effect tests that expected disposal after the gate now expect a request error. Co-authored-by: Isaac --- packages/db/src/collection/error-tree.ts | 32 ++++----- packages/db/src/query/compiler/index.ts | 7 ++ packages/db/src/query/effect.ts | 65 ++++++++++++++----- packages/db/src/query/live/ARCHITECTURE.md | 8 ++- .../src/query/live/bucket-facade-adapter.ts | 9 ++- .../query/live/collection-config-builder.ts | 23 ++++--- .../src/query/live/materialized-pipeline.ts | 8 +-- packages/db/src/query/pooled-live-query.ts | 4 +- packages/db/tests/effect.test.ts | 24 +++++-- packages/db/tests/facade-retention.probe.ts | 2 +- .../tests/query/bucket-facade-adapter.test.ts | 14 ++-- ...et-facade-rollback-oracle.property.test.ts | 6 +- .../tests/query/subset-error-matrix.test.ts | 36 ++++++---- 13 files changed, 162 insertions(+), 76 deletions(-) diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index 4b3cc16358..14d8363b7d 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -4,6 +4,8 @@ import type { CollectionImpl } from './index.js' /** A Collection that another Collection reads, under its query alias. */ export interface ErrorTreeSource { readonly alias: string + /** Tells apart two sources under one alias, such as two includes. */ + readonly id?: string readonly collection: CollectionImpl readonly kind?: ErrorTreeNode[`kind`] /** An include's per-row child Collections, for its `instances` roll-up. */ @@ -12,7 +14,7 @@ export interface ErrorTreeSource { > } -// One cached node per Collection and alias. A node keeps its identity until +// One cached node per Collection and source. A node keeps its identity until // its own fields or one of its children change, so a change replaces only the // nodes on its path and readers can compare nodes with `===`. const nodeCache = new WeakMap>() @@ -25,23 +27,18 @@ const nodeCache = new WeakMap>() */ export function buildErrorTree( collection: CollectionImpl, - alias: string = `root`, - kind?: ErrorTreeNode[`kind`], - instances?: ErrorTreeSource[`instances`], + { alias, id, kind, instances }: Omit = { + alias: `root`, + }, ): ErrorTreeNode { const sources = collection._errorTreeSources?.() ?? [] const lifecycle = collection._lifecycle const error: CollectionErrorInfo | null = lifecycle.gateProblem ?? lifecycle.health.error const children = sources.map((source) => - buildErrorTree( - source.collection, - source.alias, - source.kind, - source.instances, - ), + buildErrorTree(source.collection, source), ) - return errorTreeNode(collection, { + return errorTreeNode(collection, id, { alias, collectionId: collection.id, kind: kind ?? (collection._errorTreeSources ? `live-query` : `source`), @@ -58,10 +55,11 @@ export function buildErrorTree( */ export function errorTreeNode( owner: object, + id: string | undefined, fields: Omit, ): ErrorTreeNode { const { error, instances, sources } = fields - return cachedNode(owner, { + return cachedNode(owner, id ?? fields.alias, { ...fields, isError: error !== null, hasErrors: @@ -82,12 +80,16 @@ function countInstances( } /** Return the cached node for this Collection and alias if nothing changed. */ -function cachedNode(owner: object, next: ErrorTreeNode): ErrorTreeNode { +function cachedNode( + owner: object, + key: string, + next: ErrorTreeNode, +): ErrorTreeNode { let nodes = nodeCache.get(owner) if (!nodes) nodeCache.set(owner, (nodes = new Map())) - const previous = nodes.get(next.alias) + const previous = nodes.get(key) if (previous && sameNode(previous, next)) return previous - nodes.set(next.alias, next) + nodes.set(key, next) return next } diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 91e125f364..8db95d154f 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -49,6 +49,7 @@ import { Value as ValClass, collectCollectionSources, collectPropRefs, + collectSourceRefs, getFromSources, getHavingExpression, getWhereExpression, @@ -318,6 +319,8 @@ export interface IncludesCompilationResult { parentProjection?: Array /** How the output layer materializes the child result on the parent row */ materialization: IncludesMaterialization + /** The child query's own source IDs, not those of includes nested in it */ + sourceIds: Array /** Internal field used to unwrap scalar child selects */ scalarField?: string } @@ -935,6 +938,10 @@ function compileQueryInternal( childCompilationResult: childResult, parentProjection: subquery.parentProjection, materialization: subquery.materialization, + sourceIds: collectSourceRefs({ + ...subquery.query, + select: undefined, + }).map((source) => source.sourceId), scalarField: subquery.scalarField, }) diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 1cff153789..4954828192 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -6,6 +6,7 @@ import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' +import { describeProblem } from '../collection/lifecycle.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -40,6 +41,7 @@ import type { } from './compiler/joins.js' import type { ChangeMessage, + CollectionErrorInfo, ErrorTreeNode, KeyedStream, StringCollationConfig, @@ -473,6 +475,8 @@ class EffectPipelineRunner { // initial load has settled, with or without skipInitial. After it, a // source problem is health, not disposal. private reachedReady = false + private readonly failedRequests = new Set() + private requestProblem: CollectionErrorInfo | null = null private readonly onHealthChange: ((errors: ErrorTreeNode) => void) | undefined // Scheduler integration @@ -630,16 +634,20 @@ class EffectPipelineRunner { orderByInfo, whereExpression, ), - onLoadSubsetError: ({ error }) => { - this.onSourceError(normalizeError(error)) - }, + onLoadSubsetError: ({ error }) => + this.failLoad(sourceId, normalizeError(error)), // subscribeChanges can report a result before it returns the // subscription, so read it from the record when a load fails. - onLoadSubsetResult: (result) => + onLoadSubsetResult: (result) => { this.trackInitialLoad( result, () => this.subscriptions[sourceId]?.pendingTruncateReplacement, - ), + ) + void Promise.resolve(result).then( + () => this.settleLoad(sourceId), + () => {}, + ) + }, }) // Store subscription immediately so the join compiler can find it @@ -678,7 +686,7 @@ class EffectPipelineRunner { const lazyCallbacks = this.lazySourcesCallbacks[sourceId] if (lazyCallbacks) { lazyCallbacks.setDemand = (plan: LazyDemandPlan, keys: Set) => - this.setDemand(subscription, plan, keys) + this.setDemand(sourceId, subscription, plan, keys) for (const plan of lazyCallbacks.plans ?? []) { if (plan.initialKeys.size > 0) { lazyCallbacks.setDemand(plan, plan.initialKeys) @@ -694,6 +702,10 @@ class EffectPipelineRunner { subscription, alias, (result, holdPublication) => { + this.trackInitialLoad( + result, + () => subscription.pendingTruncateReplacement, + ) const holdsInitialPublication = this.skipInitial && !this.initialLoadComplete if (holdPublication || holdsInitialPublication) { @@ -836,6 +848,7 @@ class EffectPipelineRunner { } private setDemand( + sourceId: string, subscription: CollectionSubscription, plan: LazyDemandPlan, keys: Set, @@ -861,7 +874,9 @@ class EffectPipelineRunner { // detached error channel. void update.ready.then( () => { - if (!this.disposed) this.scheduleGraphRun() + if (this.disposed) return + this.settleLoad(sourceId) + this.scheduleGraphRun() }, () => {}, ) @@ -896,11 +911,14 @@ class EffectPipelineRunner { if (this.pendingInitialLoads.delete(result) && !this.disposed) this.scheduleGraphRun() } - // A load failure disposes the Effect through onLoadSubsetError. Any other - // rejection comes from an obsolete or aborted acquisition, so the hold - // fails open: it releases unless a truncate replacement takes it over. + // A load failure disposes the Effect through onLoadSubsetError, so a + // rejection releases one microtask later, after that event, as a live + // query's tracked rejection does. Any other rejection comes from an + // obsolete or aborted acquisition, so the hold fails open: it releases + // unless a truncate replacement takes it over. followHeldAcquisition(result, replacementAfterFailure, { - settled: release, + settled: (succeeded) => + succeeded ? release() : void Promise.resolve().then(release), replaced: (replacement) => { this.trackInitialLoad(replacement, replacementAfterFailure) release() @@ -1061,16 +1079,33 @@ class EffectPipelineRunner { return this.reachedReady } + // Before the gate, a failed load ends the Effect. After it, the failure is + // a request error on the Effect's own node, as on a live query's, until a + // later load of that source settles. + private failLoad(sourceId: string, error: Error): void { + if (!this.reachedReady) return this.onSourceError(error) + this.failedRequests.add(sourceId) + this.requestProblem = describeProblem(error, `request`, false) + this.reportHealth() + } + + private settleLoad(sourceId: string): void { + if (!this.failedRequests.delete(sourceId) || this.failedRequests.size > 0) + return + this.requestProblem = null + this.reportHealth() + } + /** The error tree for this effect's sources, in query order. */ private errorTree(): ErrorTreeNode { - return errorTreeNode(this, { + return errorTreeNode(this, undefined, { alias: `root`, collectionId: `effect`, kind: `live-query`, status: this.reachedReady ? `ready` : `loading`, - error: null, - sources: this.collectionSources.map(({ alias, collection }) => - buildErrorTree(collection, alias), + error: this.requestProblem, + sources: this.collectionSources.map(({ sourceId, alias, collection }) => + buildErrorTree(collection, { alias, id: sourceId }), ), }) } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index d95c131da6..eb6a4d4e8a 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1583,12 +1583,16 @@ create recursive Collection machinery. graph failure or a source cleaned up under the query, still end the query. So does an IndexedDB connection closure: it ends read and admission authority, which no health report can restore. An Effect has - the same two axes, with its gate at its settled initial loads. + the same two axes, with its gate at its settled initial loads: a load + that fails after the gate is a request error on the Effect's tree, + delivered through `onHealthChange`, and the Effect keeps running. 17. **Error tree:** a live query's `errors` has one node for itself and one per source alias in query order, recursing through nested live queries. An include's source has `kind: 'include'`; when the include materializes a child Collection per parent row, its node rolls those up as - `instances: { total, inError }`. A pooled live query's tree is the tree a + `instances: { total, inError }`, counting only that include's children + even when another include reads the same alias, and a child's health + change is relayed like a source's. A pooled live query's tree is the tree a live-query Collection over its source would have, so it also reaches deeper levels. The shape is fixed by the query. Each node mirrors its Collection when read, and the query emits `health:change` whenever a node diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index dff3e14f41..5e18b5c1b8 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -79,6 +79,8 @@ export class BucketFacadeAdapter { private readonly parentId: string, private readonly compilations: Array, onMessages: (count: number) => void, + // A per-row child Collection's health is part of the parent's error tree. + private readonly onInstanceHealthChange: () => void = () => {}, ) { for (const compilation of compilations) { compilation.rows.pipe( @@ -218,10 +220,10 @@ export class BucketFacadeAdapter { return this.resolveValue(value) as T } - /** The live per-row Collections of the includes that read this alias. */ - instancesFor(alias: string): Array> { + /** The live per-row Collections of the includes that read this source. */ + instancesFor(sourceId: string): Array> { return this.compilations - .filter((compilation) => compilation.aliases.includes(alias)) + .filter((compilation) => compilation.sourceIds.includes(sourceId)) .flatMap((compilation) => [ ...(this.entries.get(compilation.edgeId)?.values() ?? []), ]) @@ -464,6 +466,7 @@ export class BucketFacadeAdapter { startSync: true, gcTime: 0, }) + collection.on(`health:change`, this.onInstanceHealthChange) const dispose = collection.cleanup.bind(collection) // Other parent rows can share this facade, so a holder must not end it. collection.cleanup = () => diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 0c9ee9493c..422d1e7461 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -432,16 +432,18 @@ export class CollectionConfigBuilder< /** The source aliases of this query, in query order, for its error tree. */ errorTreeSources(): ReadonlyArray { return this.collectionSources.map(({ sourceId, alias, collection }) => { - if (!this.includeSourceIds.has(sourceId)) return { alias, collection } + const source = { id: sourceId, alias, collection } + if (!this.includeSourceIds.has(sourceId)) return source const instanced = this.bucketFacadesCache?.some((compilation) => - compilation.aliases.includes(alias), + compilation.sourceIds.includes(sourceId), ) return { - alias, - collection, + ...source, kind: `include` as const, ...(instanced - ? { instances: () => this.bucketFacades?.instancesFor(alias) ?? [] } + ? { + instances: () => this.bucketFacades?.instancesFor(sourceId) ?? [], + } : {}), } }) @@ -977,9 +979,14 @@ export class CollectionConfigBuilder< // and pays for no facade state. const facadeCompilations = this.bucketFacadesCache const bucketFacades = facadeCompilations - ? new BucketFacadeAdapter(this.id, facadeCompilations, (count) => { - syncState.messagesCount += count - }) + ? new BucketFacadeAdapter( + this.id, + facadeCompilations, + (count) => { + syncState.messagesCount += count + }, + () => this.liveQueryCollection?._emitHealthChange(), + ) : undefined this.bucketFacades = bucketFacades if (bucketFacades) { diff --git a/packages/db/src/query/live/materialized-pipeline.ts b/packages/db/src/query/live/materialized-pipeline.ts index db8852c3bf..569f58a6e9 100644 --- a/packages/db/src/query/live/materialized-pipeline.ts +++ b/packages/db/src/query/live/materialized-pipeline.ts @@ -55,8 +55,8 @@ export type BucketRow = readonly [ export type BucketFacadeCompilation = { edgeId: string - /** The include's FROM and JOIN aliases, whose error-tree nodes roll up its instances. */ - aliases: Array + /** The include's own source IDs, whose error-tree nodes roll up its instances. */ + sourceIds: Array rows: IStreamBuilder<[string, BucketRow]> activeBuckets: IStreamBuilder<[string, true]> hasOrderBy: boolean @@ -167,9 +167,7 @@ function materializeRelation( ) as IStreamBuilder<[string, BucketRow]> facades.push({ edgeId, - aliases: Object.keys( - include.childCompilationResult.aliasToCollectionId, - ), + sourceIds: include.sourceIds, rows: activeBucketRows, activeBuckets, hasOrderBy: include.hasOrderBy, diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 6995921241..41107be72f 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -517,7 +517,7 @@ class PooledLiveQuery { /** The same error tree a live-query Collection over this source exposes. */ get errors(): ErrorTreeNode { - const source = buildErrorTree(this.source, this.alias) + const source = buildErrorTree(this.source, { alias: this.alias }) // The view shows its source's gate. A terminated partition also ended // this view, for example when its source was cleaned up under it. const error = @@ -533,7 +533,7 @@ class PooledLiveQuery { true, )) : null) - return errorTreeNode(this, { + return errorTreeNode(this, undefined, { alias: `root`, collectionId: this.source.id, kind: `live-query`, diff --git a/packages/db/tests/effect.test.ts b/packages/db/tests/effect.test.ts index eb784ff7aa..a462d185fc 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -8,6 +8,7 @@ import { } from './utils.js' import type { DeltaEvent, + ErrorTreeNode, SubscriptionLoadSubsetErrorEvent, } from '../src/index.js' @@ -1968,6 +1969,7 @@ describe(`createEffect`, () => { }, }) const sourceErrors: Array = [] + const trees: Array = [] const effect = createEffect({ query: (q) => q @@ -1981,6 +1983,7 @@ describe(`createEffect`, () => { })), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), }) try { @@ -1994,8 +1997,14 @@ describe(`createEffect`, () => { } expect(commitError).toBeUndefined() - expect(sourceErrors).toEqual([failure]) - expect(effect.disposed).toBe(true) + // After the Effect's gate, a failed load is a request error on its + // tree; the Effect keeps running. + expect(sourceErrors).toEqual([]) + expect(effect.disposed).toBe(false) + expect(trees.at(-1)?.error).toMatchObject({ + phase: `request`, + message: failure.message, + }) } finally { await effect.dispose() await Promise.all([users.cleanup(), issues.cleanup()]) @@ -2039,6 +2048,7 @@ describe(`createEffect`, () => { }, }) const sourceErrors: Array = [] + const trees: Array = [] const effect = createEffect({ query: (q) => q @@ -2052,6 +2062,7 @@ describe(`createEffect`, () => { })), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), }) try { @@ -2065,8 +2076,13 @@ describe(`createEffect`, () => { }).not.toThrow() await flushPromises() - expect(sourceErrors).toEqual([failure]) - expect(effect.disposed).toBe(true) + // A failed release after the gate is a request error. + expect(sourceErrors).toEqual([]) + expect(effect.disposed).toBe(false) + expect(trees.at(-1)?.error).toMatchObject({ + phase: `request`, + message: failure.message, + }) expect(unloadCount).toBe(1) await effect.dispose() diff --git a/packages/db/tests/facade-retention.probe.ts b/packages/db/tests/facade-retention.probe.ts index c1a8f66f8a..15c136952a 100644 --- a/packages/db/tests/facade-retention.probe.ts +++ b/packages/db/tests/facade-retention.probe.ts @@ -27,7 +27,7 @@ function capture( [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows, activeBuckets, hasOrderBy: false, diff --git a/packages/db/tests/query/bucket-facade-adapter.test.ts b/packages/db/tests/query/bucket-facade-adapter.test.ts index 5e25ab05c2..297cda9016 100644 --- a/packages/db/tests/query/bucket-facade-adapter.test.ts +++ b/packages/db/tests/query/bucket-facade-adapter.test.ts @@ -70,7 +70,7 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows, activeBuckets, hasOrderBy: false, @@ -134,7 +134,7 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows, activeBuckets, hasOrderBy: ordered, @@ -221,7 +221,7 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows, activeBuckets, hasOrderBy: true, @@ -275,7 +275,7 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows, activeBuckets, hasOrderBy: true, @@ -443,14 +443,14 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `first`, - aliases: [], + sourceIds: [], rows: firstRows, activeBuckets: firstActiveBuckets, hasOrderBy: false, }, { edgeId: `second`, - aliases: [], + sourceIds: [], rows: secondRows, activeBuckets: secondActiveBuckets, hasOrderBy: false, @@ -546,7 +546,7 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows, activeBuckets, hasOrderBy: false, diff --git a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts index d79bbd86a2..2c0e82a2bc 100644 --- a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts @@ -408,7 +408,7 @@ class Driver { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows: this.rows, activeBuckets: this.activity, hasOrderBy: true, @@ -1428,14 +1428,14 @@ describe(`bucket facade rollback`, () => { [ { edgeId: `children`, - aliases: [], + sourceIds: [], rows: childRows, activeBuckets: childActive, hasOrderBy: false, }, { edgeId: `parents`, - aliases: [], + sourceIds: [], rows: parentRows, activeBuckets: parentActive, hasOrderBy: false, diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index 687ed9e43f..79eaa3de2a 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -4,6 +4,7 @@ import { BTreeIndex } from '../../src/indexes/btree-index.js' import { createEffect, createLiveQueryCollection, eq } from '../../src/index.js' import { getLoadSubsetDemandKey } from '../../src/query/ir-stable-identity.js' import { mockSyncCollectionOptions } from '../utils.js' +import type { ErrorTreeNode } from '../../src/types.js' type Delivery = `throw` | `reject` type Consumer = `effect` | `live` @@ -127,10 +128,12 @@ function startEffect( primary: RowCollection, child: RowCollection, sourceErrors: Array, + trees: Array = [], ) { const callbacks = { onBatch: () => {}, onSourceError: (error: Error) => sourceErrors.push(error), + onHealthChange: (errors: ErrorTreeNode) => trees.push(errors), } if (path === `ordered`) { return createEffect({ @@ -330,7 +333,8 @@ describe(`loadSubset failure matrix`, () => { try { if (consumer === `effect`) { const sourceErrors: Array = [] - const effect = startEffect(path, primary, child, sourceErrors) + const trees: Array = [] + const effect = startEffect(path, primary, child, sourceErrors, trees) try { await flushFailures() expect(sourceErrors).toEqual([]) @@ -338,13 +342,17 @@ describe(`loadSubset failure matrix`, () => { expect(() => triggerFailure()).not.toThrow() await flushFailures() - expect(sourceErrors).toHaveLength(1) - if (failureValue === `error`) { - expect(sourceErrors[0]).toBe(error) - } else { - expect(sourceErrors[0]).toBeInstanceOf(Error) - } - expect(effect.disposed).toBe(true) + // After the Effect's gate, a failed load is a request error on + // its tree, as on a live query's node; the Effect keeps running. + expect(sourceErrors).toEqual([]) + expect(effect.disposed).toBe(false) + expect(trees.at(-1)?.error).toMatchObject({ + phase: `request`, + final: false, + ...(failureValue === `error` + ? { message: (error as Error).message } + : {}), + }) } finally { await effect.dispose() } @@ -431,6 +439,7 @@ describe(`loadSubset failure matrix`, () => { }, }) const sourceErrors: Array = [] + const trees: Array = [] const effect = consumer === `effect` ? createEffect({ @@ -442,6 +451,7 @@ describe(`loadSubset failure matrix`, () => { ), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), }) : undefined const live = @@ -468,9 +478,13 @@ describe(`loadSubset failure matrix`, () => { await flushFailures() if (effect) { - expect(sourceErrors).toHaveLength(1) - expect(sourceErrors[0]?.message).toBe(String(failure)) - expect(effect.disposed).toBe(true) + // A failed release after the gate is a request error. + expect(sourceErrors).toEqual([]) + expect(effect.disposed).toBe(false) + expect(trees.at(-1)?.error).toMatchObject({ + phase: `request`, + message: String(failure), + }) } if (live) { expect(live.utils.lastSubsetError).toBeInstanceOf(Error) From b1304a0a91addf38f83c3240fc7d3ce5e55ac3bb Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:17:59 -0600 Subject: [PATCH 011/106] feat(db-sqlite-persistence-core): forward upstream health reports through persisted wrapper Add reportError and reportHealthy forwarding from the upstream source through the persisted wrapper. After persisted startup settles, upstream health reports (both errors and recovery) are forwarded to the wrapped Collection, keeping local storage failures separate on persistedError per the PLAN 3.2 contract. Followers still learn leader upstream health through coordinator broadcast (open question, design deferred). Co-authored-by: Isaac --- .../src/persisted.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 30d9aa8861..4b61074912 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -5880,9 +5880,34 @@ function createWrappedSyncConfig< } let sourceSession = 0 + const reportUpstreamHealthy = () => { + if (startupState.cleanedUp || getTerminalFailure()) return + void (fullStartPromise ?? runtime.ensureStarted()) + .then(() => { + if (!isCleanedUp() && !getTerminalFailure()) { + params.reportHealthy() + } + }) + .catch(() => undefined) + } + const reportUpstreamErrorForHealth = ( + error: unknown, + options?: { final?: boolean }, + ) => { + if (startupState.cleanedUp || getTerminalFailure()) return + void (fullStartPromise ?? runtime.ensureStarted()) + .then(() => { + if (!isCleanedUp() && !getTerminalFailure()) { + params.reportError(error, options) + } + }) + .catch(() => undefined) + } const wrappedParams = { ...params, markError: reportUpstreamError, + reportError: reportUpstreamErrorForHealth, + reportHealthy: reportUpstreamHealthy, markReady: () => { if (startupState.cleanedUp || getTerminalFailure()) return void (fullStartPromise ?? runtime.ensureStarted()) From 73428829ac94a519a81b7a78e97787521ea8b13d Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:21:51 -0600 Subject: [PATCH 012/106] feat(adapters): implement post-readiness health error reporting Add reportError() and reportHealthy() calls to Electric, TrailBase, and RxDB adapters to report Collection health for failures after initial-query readiness. - Electric: Report transaction commit failures post-readiness as final health errors - TrailBase: Report stream failures post-readiness as final health errors - RxDB: Report fetch failures post-readiness as final health errors - All adapters: Call reportHealthy() after successful recovery from health error Post-readiness failures no longer transition Collection status to error; instead, they report as health errors via the Collection health channel. Status remains ready while data persists, and rows reflect the stale state until recovery. Co-authored-by: Isaac --- .../electric-db-collection/src/electric.ts | 20 ++++++++++++- packages/rxdb-db-collection/src/rxdb.ts | 12 +++++++- .../trailbase-db-collection/src/trailbase.ts | 29 +++++++++++++++---- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index bb5f8b6048..84f7e37503 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1759,6 +1759,8 @@ function createElectricSync>( commit: commitSyncTransaction, markReady, markError, + reportError, + reportHealthy, truncate, collection, metadata, @@ -1896,6 +1898,7 @@ function createElectricSync>( // Wrap markReady to wait for test hook in progressive mode let progressiveReadyGate: Promise | null = null let streamErrorVersion = 0 + let hasHealthError = false const wrappedMarkReady = ( isBuffering: boolean, expectedErrorVersion = streamErrorVersion, @@ -1920,6 +1923,12 @@ function createElectricSync>( // No hook, not buffering, or already past first up-to-date markReady() } + + // Report Collection health recovery if we've recovered from a health error + if (hasHealthError && collection.status === `ready`) { + hasHealthError = false + reportHealthy() + } } // Abort controller for the stream - wraps the signal if provided @@ -2653,7 +2662,16 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - if (collection.status !== `error`) markError(error) + if (collection.status !== `error`) { + if (collection.status === `ready`) { + // Post-readiness transaction failure: sync adapter is giving up + hasHealthError = true + reportError(error, { final: true }) + } else { + // Pre-readiness failure + markError(error) + } + } }, ) } diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index a5140b2031..fb98a5e112 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -126,9 +126,10 @@ export function rxdbCollectionOptions( type SyncParams = Parameters[`sync`]>[0] const sync: SyncConfig = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, collection } = params + const { begin, write, commit, markReady, markError, reportError, reportHealthy, collection } = params let initialFetchComplete = false + let hasHealthError = false async function initialFetch() { /** * RxDB stores a last-write-time @@ -274,6 +275,11 @@ export function rxdbCollectionOptions( if (!isCleanedUp()) { markReady() + // Report Collection health recovery if we've recovered from a health error + if (hasHealthError && collection.status === `ready`) { + hasHealthError = false + reportHealthy() + } } } @@ -281,6 +287,10 @@ export function rxdbCollectionOptions( stopOngoingFetch() if (collection.status === `loading`) { markError(error) + } else if (collection.status === `ready`) { + // Post-readiness fetch failure: sync adapter is giving up + hasHealthError = true + reportError(error, { final: true }) } }) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 27c3a9decb..5a3aaa8bf6 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -185,8 +185,9 @@ export function trailBaseCollectionOptions< type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, collection } = params + const { begin, write, commit, markReady, markError, reportError, reportHealthy, collection } = params let cancelled = false + let hasHealthError = false let periodicCleanupTask: ReturnType | undefined const cleanup = () => { cancelled = true @@ -422,10 +423,16 @@ export function trailBaseCollectionOptions< if (eventReader === subscribedReader) eventReader = undefined }) .catch((error: unknown) => { - if (!cancelled && collection.status === `loading`) { - markError(error) - } else if (!cancelled) { - console.error(`TrailBase subscription failed`, error) + if (!cancelled) { + if (collection.status === `loading`) { + markError(error) + } else if (collection.status === `ready`) { + // Post-readiness stream failure: sync adapter is giving up + hasHealthError = true + reportError(error, { final: true }) + } else { + console.error(`TrailBase subscription failed`, error) + } } }) listening = true @@ -442,16 +449,26 @@ export function trailBaseCollectionOptions< markReady() } } catch (error) { - // An abandoned startup must not cancel a replacement session's reader. + // An abandoned startup must not cancel a replacement provider session's reader. if (cancelled) return settleListener({ error }) cancelEventReader() if (collection.status === `loading`) { markError(error) + } else if (collection.status === `ready`) { + // Post-readiness stream failure: sync adapter is giving up + hasHealthError = true + reportError(error, { final: true }) } return } + // Report Collection health recovery if listener settled successfully after a health error + if (hasHealthError && collection.status === `ready`) { + hasHealthError = false + reportHealthy() + } + // Lastly, start a periodic cleanup task that will be removed when the // reader closes. if (cancelled || !reader) return From 7e42dc9b0facc46c4178247e1b08086f7df573f1 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:25:14 -0600 Subject: [PATCH 013/106] feat(hooks): add errors return value to all framework hooks All live-query hooks now return the error tree alongside existing fields. The error tree (ErrorTreeNode) shows post-ready health per collection. - React: useLiveQuery, useLiveInfiniteQuery, useLiveSuspenseQuery - Vue: useLiveQuery, useLiveInfiniteQuery - Solid: useLiveQuery - Svelte: useLiveQuery, useLiveInfiniteQuery - Angular: injectLiveQuery Also update LiveQueryWindowSnapshot to include errors for infinite queries. Co-authored-by: Isaac --- packages/angular-db/src/index.ts | 9 +++++++++ packages/db/src/live-query-window-controller.ts | 10 ++++++++-- packages/react-db/src/useLiveInfiniteQuery.ts | 3 +++ packages/react-db/src/useLiveQuery.ts | 12 ++++++++++++ packages/react-db/src/useLiveSuspenseQuery.ts | 6 ++++++ packages/solid-db/src/useLiveQuery.ts | 6 ++++++ packages/svelte-db/src/useLiveQuery.svelte.ts | 3 +++ packages/vue-db/src/useLiveInfiniteQuery.ts | 4 ++++ packages/vue-db/src/useLiveQuery.ts | 7 +++++++ 9 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 1682546ae7..19c32c5630 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -19,6 +19,7 @@ import type { Collection, CollectionStatus, Context, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -63,6 +64,8 @@ export interface InjectLiveQueryResult { isError: Signal /** A signal indicating whether the collection has been cleaned up */ isCleanedUp: Signal + /** A signal containing the error tree for the query and its dependencies */ + errors: Signal } type InferConditionalResultType = @@ -94,6 +97,7 @@ export interface InjectLiveQueryResultWithCollection< isIdle: Signal isError: Signal isCleanedUp: Signal + errors: Signal } export interface InjectLiveQueryResultWithSingleResultCollection< @@ -113,6 +117,7 @@ export interface InjectLiveQueryResultWithSingleResultCollection< isIdle: Signal isError: Signal isCleanedUp: Signal + errors: Signal } export function injectLiveQuery< @@ -227,6 +232,7 @@ export function injectLiveQuery(opts: any) { }) const persistedStatus = signal(`unavailable`) const persistedError = signal(undefined) + const errors = signal(undefined) // Returns single item for singleResult collections, array otherwise const data = computed(() => { @@ -252,6 +258,7 @@ export function injectLiveQuery(opts: any) { statusValue.set(snapshot.status) persistedStatus.set(snapshot.persistedStatus) persistedError.set(snapshot.persistedError) + errors.set(snapshot.errors) } let unsub: (() => void) | null = null @@ -268,6 +275,7 @@ export function injectLiveQuery(opts: any) { statusValue.set(`disabled` as const) persistedStatus.set(`unavailable`) persistedError.set(undefined) + errors.set(undefined) state.set(new Map()) internalData.set([]) cleanup() @@ -318,5 +326,6 @@ export function injectLiveQuery(opts: any) { isIdle: computed(() => status() === `idle`), isError: computed(() => status() === `error`), isCleanedUp: computed(() => status() === `cleaned-up`), + errors, } } diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 98b5a191cc..09fe6e49f6 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -16,7 +16,7 @@ import type { LiveQuerySnapshot, } from './live-query-observer.js' import type { Collection } from './collection/index.js' -import type { CollectionStatus } from './types.js' +import type { CollectionStatus, ErrorTreeNode } from './types.js' import type { Context, InitialQueryBuilder, @@ -537,6 +537,8 @@ export interface LiveQueryWindowSnapshot< isError: boolean isCleanedUp: boolean isEnabled: boolean + /** The error tree for the collection and its dependencies, or `undefined` when disabled. */ + errors: ErrorTreeNode | undefined } /** @internal This contract is unstable while RFC #1623 is being implemented. */ @@ -587,6 +589,7 @@ interface CachedFrom { hasPaginationError: boolean paginationError: unknown failedHasNextPage: boolean + errors: unknown } interface SubscriptionRecord { @@ -668,7 +671,8 @@ class LiveQueryWindowControllerImpl< this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage && this.cachedFrom.hasPaginationError === this.hasPaginationError && this.cachedFrom.paginationError === this.paginationError && - this.cachedFrom.failedHasNextPage === this.failedHasNextPage + this.cachedFrom.failedHasNextPage === this.failedHasNextPage && + this.cachedFrom.errors === observerSnapshot.errors ) { return cached } @@ -717,6 +721,7 @@ class LiveQueryWindowControllerImpl< isError: statusFlags.isError, isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, + errors: observerSnapshot.errors, } this.cachedFrom = { observerSnapshot, @@ -725,6 +730,7 @@ class LiveQueryWindowControllerImpl< hasPaginationError: this.hasPaginationError, paginationError: this.paginationError, failedHasNextPage: this.failedHasNextPage, + errors: observerSnapshot.errors, } return this.cachedSnapshot } diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index ca4e0b8da5..245ad90e1f 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -38,6 +38,7 @@ import type { CollectionStatus, Context, DbClient, + ErrorTreeNode, InferResultType, InitialQueryBuilder, LiveQueryPersistedStatus, @@ -102,6 +103,7 @@ export type UseLiveInfiniteQueryReturnWithCollection< hasNextPage: boolean isFetchingNextPage: boolean error: unknown + errors: ErrorTreeNode | undefined } type EnabledLiveQueryReturn = ReturnType< @@ -471,5 +473,6 @@ export function useLiveInfiniteQuery( hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, error: snapshot.error, + errors: snapshot.errors, } } diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index d8f532ef6e..f5a4ca24e2 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -27,6 +27,7 @@ import type { CollectionStatus, Context, DbClient, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -401,6 +402,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: true // Always true if always returns QueryBuilder + errors: ErrorTreeNode | undefined } // Overload 2: Accept query function that can return undefined/null @@ -423,6 +425,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: boolean + errors: ErrorTreeNode | undefined } // Overload 3: Accept query function that can return LiveQueryCollectionConfig @@ -445,6 +448,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: boolean + errors: ErrorTreeNode | undefined } // Overload 4: Accept query function that can return Collection @@ -471,6 +475,7 @@ export function useLiveQuery< isError: boolean isCleanedUp: boolean isEnabled: boolean + errors: ErrorTreeNode | undefined } // Overload 5: Accept query function that can return all types @@ -507,6 +512,7 @@ export function useLiveQuery< isError: boolean isCleanedUp: boolean isEnabled: boolean + errors: ErrorTreeNode | undefined } /** @@ -561,6 +567,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: true // Always true when query always returns a builder + errors: ErrorTreeNode | undefined } // Overload 7: Accept config object with a query that can return undefined/null @@ -580,6 +587,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: boolean + errors: ErrorTreeNode | undefined } // Overload 8: Accept config object with legacy deps @@ -600,6 +608,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: true // Always true when query always returns a builder + errors: ErrorTreeNode | undefined } // Overload 9: Accept config object with legacy deps and a query that can return undefined/null @@ -620,6 +629,7 @@ export function useLiveQuery( isError: boolean isCleanedUp: boolean isEnabled: boolean + errors: ErrorTreeNode | undefined } /** @@ -672,6 +682,7 @@ export function useLiveQuery< isError: boolean isCleanedUp: boolean isEnabled: true // Always true for pre-created live query collections + errors: ErrorTreeNode | undefined } // Overload 10: Accept pre-created live query collection with singleResult: true @@ -695,6 +706,7 @@ export function useLiveQuery< isError: boolean isCleanedUp: boolean isEnabled: true // Always true for pre-created live query collections + errors: ErrorTreeNode | undefined } // Implementation - use function overloads to infer the actual collection type diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 28c39bab8e..29c05f7692 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -8,6 +8,7 @@ import type { Collection, Context, DbClient, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -155,6 +156,7 @@ export function useLiveSuspenseQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> + errors: ErrorTreeNode | undefined } // Overload 2: Accept config object @@ -164,6 +166,7 @@ export function useLiveSuspenseQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> + errors: ErrorTreeNode | undefined } // Overload 3: Accept legacy config object @@ -174,6 +177,7 @@ export function useLiveSuspenseQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> + errors: ErrorTreeNode | undefined } // Overload 4: Accept pre-created live query collection @@ -187,6 +191,7 @@ export function useLiveSuspenseQuery< state: Map data: Array collection: Collection + errors: ErrorTreeNode | undefined } // Overload 5: Accept pre-created live query collection with singleResult: true @@ -200,6 +205,7 @@ export function useLiveSuspenseQuery< state: Map data: TResult | undefined collection: Collection & SingleResult + errors: ErrorTreeNode | undefined } // Implementation - uses useLiveQuery internally and adds Suspense logic diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 21963fa948..f456caf7be 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -23,6 +23,7 @@ import type { Collection, CollectionStatus, Context, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -128,6 +129,7 @@ export function useLiveQuery( isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } // Overload 1b: Accept query function that can return undefined/null @@ -152,6 +154,7 @@ export function useLiveQuery( isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } /** @@ -214,6 +217,7 @@ export function useLiveQuery( isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } /** @@ -277,6 +281,7 @@ export function useLiveQuery< isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } // Overload 3b: Accept pre-created live query collection with singleResult: true @@ -305,6 +310,7 @@ export function useLiveQuery< isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } // Implementation - use function overloads to infer the actual collection type diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 31123006fd..86da498000 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -24,6 +24,7 @@ import type { Context, DbClient, DeferredLiveQueryCollections, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -67,6 +68,7 @@ export interface UseLiveQueryReturn> { isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } type InferConditionalResultType = @@ -100,6 +102,7 @@ export interface UseLiveQueryReturnWithCollection< isIdle: boolean isError: boolean isCleanedUp: boolean + errors: ErrorTreeNode | undefined } type MaybeGetter = T | (() => T) diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 33b93631da..454f47902c 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -14,6 +14,7 @@ import type { Collection, CollectionStatus, Context, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -68,6 +69,7 @@ export interface UseLiveInfiniteQueryReturn< hasNextPage: ComputedRef isFetchingNextPage: ComputedRef error: ComputedRef + errors: ComputedRef } export interface UseLiveInfiniteQueryReturnWithCollection< @@ -93,6 +95,7 @@ export interface UseLiveInfiniteQueryReturnWithCollection< hasNextPage: ComputedRef isFetchingNextPage: ComputedRef error: ComputedRef + errors: ComputedRef } /** @@ -269,5 +272,6 @@ export function useLiveInfiniteQuery< hasNextPage: computed(() => snapshot.value.hasNextPage), isFetchingNextPage: computed(() => snapshot.value.isFetchingNextPage), error: computed(() => snapshot.value.error), + errors: computed(() => snapshot.value.errors), } } diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index b92492b98d..0ca43d0d30 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -22,6 +22,7 @@ import type { Collection, CollectionStatus, Context, + ErrorTreeNode, GetResult, InferResultType, InitialQueryBuilder, @@ -65,6 +66,7 @@ export interface UseLiveQueryReturn { isIdle: ComputedRef isError: ComputedRef isCleanedUp: ComputedRef + errors: ComputedRef } type InferConditionalResultType = @@ -102,6 +104,7 @@ export interface UseLiveQueryReturnWithCollection< isIdle: ComputedRef isError: ComputedRef isCleanedUp: ComputedRef + errors: ComputedRef } export interface UseLiveQueryReturnWithSingleResultCollection< @@ -121,6 +124,7 @@ export interface UseLiveQueryReturnWithSingleResultCollection< isIdle: ComputedRef isError: ComputedRef isCleanedUp: ComputedRef + errors: ComputedRef } /** @@ -374,6 +378,7 @@ export function useLiveQuery( ) const persistedStatus = ref(`unavailable`) const persistedError = shallowRef(undefined) + const errors = shallowRef(undefined) // The shared observer owns subscription, the ready-race, and status; Vue // materializes into its own reactive map (granular) + ordered array. @@ -384,6 +389,7 @@ export function useLiveQuery( status.value = snapshot.status as CollectionStatus persistedStatus.value = snapshot.persistedStatus persistedError.value = snapshot.persistedError + errors.value = snapshot.errors internalData.value = snapshotRows(snapshot.data) } @@ -468,5 +474,6 @@ export function useLiveQuery( isIdle: computed(() => status.value === `idle`), isError: computed(() => status.value === `error`), isCleanedUp: computed(() => status.value === `cleaned-up`), + errors: computed(() => errors.value), } } From aa2cde8948bc472b958be137d4e33d5b8ce3fa64 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:25:47 -0600 Subject: [PATCH 014/106] feat(adapters): add health reporting for query-db and PowerSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement health reporting for two adapters per PLAN section 3.2: query-db (packages/query-db-collection/src/query.ts): - Extract reportError and reportHealthy from sync params - Guard markReady() call: skip if collection.status is 'error' Initial-load failure is final; successful refetches don't revive status - Call reportHealthy() when result application succeeds after prior errors - Call reportError(error, { final: false }) when result application fails after collection is ready - Add comments explaining utils.lastError/isError/errorCount as query-level error tracking (distinct from Collection health) PowerSync (packages/powersync-db-collection/src/powersync.ts): - Extract reportError and reportHealthy from sync params - After successful tracking rebuild: * Skip if status is 'error' (recovery needs cleanup+preload) * Call markReady() if status is 'loading' (initial load path) * Call reportHealthy() if status is 'ready' (post-readiness recovery) - On tracking rebuild failure: * Call markError() if status is 'loading' (initial-load failure) * Call reportError(error, { final: false }) if status is 'ready' (post-readiness failure via health, not status change) Design principles applied per ARCHITECTURE laws 16-17: - Initial errors (markError) are final for the sync run - After initial-query readiness, reportError/reportHealthy track health - Recovery from initial errors requires cleanup+preload restart - Post-readiness failures are health metadata, not status changes - error→ready transitions no longer allowed per law 16 Code weight: - query-db: +~30 net lines (guard, health calls, comments) - PowerSync: +~20 net lines (status checks, health calls) Co-authored-by: Isaac --- .../powersync-db-collection/src/powersync.ts | 33 +++++++++++++-- packages/query-db-collection/src/query.ts | 40 +++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 3da2a4d0ca..2e8dccd33b 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -325,7 +325,16 @@ function createPowerSyncCollectionConfig< */ const sync: SyncConfig = { sync: (params) => { - const { begin, write, collection, commit, markReady, markError } = params + const { + begin, + write, + collection, + commit, + markReady, + markError, + reportError, + reportHealthy, + } = params const abortController = new AbortController() let disposeTracking: @@ -810,7 +819,17 @@ function createPowerSyncCollectionConfig< reconciledTrackingRevision = revision // Replacing the trigger alone is not recovery: its baseline // writes must also be applied before the source is ready again. - if (collection.status === `error`) markReady() + if (collection.status === `error`) { + // After markError, recovery requires cleanup() then preload(). + // Do nothing here; the error stays final. + } else if (collection.status === `loading`) { + // Before initial-query readiness, move to ready + markReady() + } else if (collection.status === `ready`) { + // After initial-query readiness, a successful rebuild reports + // recovery through Collection health. + reportHealthy() + } } } } @@ -823,7 +842,15 @@ function createPowerSyncCollectionConfig< .catch((error) => { // A rebuild may already have removed every active diff trigger. // Do not leave healthy consumers ready against a stale source. - if (!stopped) markError(error) + if (!stopped) { + if (collection.status === `loading`) { + // Initial load failure is final for the attempt + markError(error) + } else { + // Post-ready failures are reported as health issues + reportError(error, { final: false }) + } + } throw error }) .finally(() => { diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 087babc903..de11c102a6 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -431,6 +431,11 @@ class QueryCollectionUtilsImpl implements QueryCollectionUtils { } // Getters for error state + // These track query-level errors (per query, not per collection). They are + // separate from Collection.health. When status is ready, query errors appear + // only in health; when status is error, they appear here too as the initial + // load failed. After ready, a failed query fetch sets health as a request + // error and keeps the last ready data while tracking in lastError/errorCount. public get lastError() { return this.state.lastError } @@ -1147,8 +1152,17 @@ export function queryCollectionOptions( ) } } - const { begin, write, commit, markReady, markError, collection, metadata } = - params + const { + begin, + write, + commit, + markReady, + markError, + reportError, + reportHealthy, + collection, + metadata, + } = params const persistence = metadata === undefined ? null @@ -2673,7 +2687,15 @@ export function queryCollectionOptions( if (unownedDirectWritePositions.get(key) === position) unownedDirectWritePositions.delete(key) }) - if (!signal?.aborted) markReady() + if (!signal?.aborted) { + // A successful refetch after an initial load failure does not revive + // the Collection. The failure is final; recovery needs cleanup() then + // preload(). After initial-query readiness, successful results report + // health recovery in trackResultApplication. + if (collection.status !== `error`) { + markReady() + } + } } const accepted = whenSyncAccepted(applied) if (accepted === true) finishApplication() @@ -2756,11 +2778,16 @@ export function queryCollectionOptions( void application.then( () => { if (finish()) { + const hadError = failedResultApplications.has(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) // Applying cached data during a fetch does not establish recovery. if (!isFetching && applicationErrorRevision === errorRevision) { state.lastError = undefined state.errorCount = 0 + // Report recovery after a problem if the collection is ready + if (hadError && collection.status === `ready`) { + reportHealthy() + } } } }, @@ -2774,6 +2801,10 @@ export function queryCollectionOptions( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) + // Report post-ready failures as health issues (not final) + if (collection.status === `ready`) { + reportError(error, { final: false }) + } if (collection.status === `loading`) { markError(error) } @@ -3149,6 +3180,9 @@ export function queryCollectionOptions( // snapshot available while utils expose the error. if (collection.status === `loading`) { markError(result.error) + } else if (collection.status === `ready`) { + // Report post-ready failures as health issues (not final) + reportError(result.error, { final: false }) } } } From 0238a58a634d90456e8f0e0aaf4710cd4d539a3b Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:29:58 -0600 Subject: [PATCH 015/106] fix(adapters): remove dead code and add post-readiness error reporting Remove unreachable reportHealthy() calls that follow final errors. Since all post-readiness adapter failures are fatal (adapter gives up, no resubscribe), errors are always reported as final. Add missing post-readiness handling for stream errors: Electric stream.subscribe error handler and resume rejection; clarify adapter behavior with updated comments. Electric (3 sites): transaction commit failure, SDK give-up, resume rejection. TrailBase (2 sites): stream listener error, stream reader catch error. RxDB (1 site): start async error. Co-authored-by: Isaac --- .../electric-db-collection/src/electric.ts | 39 ++++++++++--------- packages/rxdb-db-collection/src/rxdb.ts | 20 +++++----- .../trailbase-db-collection/src/trailbase.ts | 24 ++++++------ 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 84f7e37503..03fba8d5c2 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1760,7 +1760,7 @@ function createElectricSync>( markReady, markError, reportError, - reportHealthy, + reportHealthy: _reportHealthy, truncate, collection, metadata, @@ -1898,7 +1898,6 @@ function createElectricSync>( // Wrap markReady to wait for test hook in progressive mode let progressiveReadyGate: Promise | null = null let streamErrorVersion = 0 - let hasHealthError = false const wrappedMarkReady = ( isBuffering: boolean, expectedErrorVersion = streamErrorVersion, @@ -1923,12 +1922,6 @@ function createElectricSync>( // No hook, not buffering, or already past first up-to-date markReady() } - - // Report Collection health recovery if we've recovered from a health error - if (hasHealthError && collection.status === `ready`) { - hasHealthError = false - reportHealthy() - } } // Abort controller for the stream - wraps the signal if provided @@ -2664,8 +2657,8 @@ function createElectricSync>( abortController.abort() if (collection.status !== `error`) { if (collection.status === `ready`) { - // Post-readiness transaction failure: sync adapter is giving up - hasHealthError = true + // Post-readiness transaction failure: sync adapter gives up + // and will not retry. reportError(error, { final: true }) } else { // Pre-readiness failure @@ -2726,15 +2719,16 @@ function createElectricSync>( }, (error) => { // The SDK gave up: it rejected the retry decision or exhausted its - // retry loop. No provider batch will settle the gate, and before the - // first ready this is the source's final initial failure. + // retry loop. No provider batch will settle the gate. rejectFullSnapshot(error) - if ( - isActiveLifecycle() && - !abortController.signal.aborted && - collection.status === `loading` - ) - failInitialSync(error) + if (isActiveLifecycle() && !abortController.signal.aborted) { + if (collection.status === `loading`) { + failInitialSync(error) + } else if (collection.status === `ready`) { + // Post-readiness stream failure: SDK gives up and will not retry + reportError(error, { final: true }) + } + } }, ) @@ -2758,7 +2752,14 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - markError(error) + if (collection.status === `ready`) { + // Post-readiness resume failure: adapter cannot proceed with invalid + // resume state and will not retry + reportError(error, { final: true }) + } else { + // Pre-readiness failure + markError(error) + } }, ) } diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index fb98a5e112..4169f5c6f2 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -126,10 +126,18 @@ export function rxdbCollectionOptions( type SyncParams = Parameters[`sync`]>[0] const sync: SyncConfig = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, reportError, reportHealthy, collection } = params + const { + begin, + write, + commit, + markReady, + markError, + reportError, + reportHealthy: _reportHealthy, + collection, + } = params let initialFetchComplete = false - let hasHealthError = false async function initialFetch() { /** * RxDB stores a last-write-time @@ -275,11 +283,6 @@ export function rxdbCollectionOptions( if (!isCleanedUp()) { markReady() - // Report Collection health recovery if we've recovered from a health error - if (hasHealthError && collection.status === `ready`) { - hasHealthError = false - reportHealthy() - } } } @@ -288,8 +291,7 @@ export function rxdbCollectionOptions( if (collection.status === `loading`) { markError(error) } else if (collection.status === `ready`) { - // Post-readiness fetch failure: sync adapter is giving up - hasHealthError = true + // Post-readiness fetch failure: sync adapter does not retry reportError(error, { final: true }) } }) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 5a3aaa8bf6..9f143d4b40 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -185,9 +185,17 @@ export function trailBaseCollectionOptions< type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, reportError, reportHealthy, collection } = params + const { + begin, + write, + commit, + markReady, + markError, + reportError, + reportHealthy: _reportHealthy, + collection, + } = params let cancelled = false - let hasHealthError = false let periodicCleanupTask: ReturnType | undefined const cleanup = () => { cancelled = true @@ -427,8 +435,7 @@ export function trailBaseCollectionOptions< if (collection.status === `loading`) { markError(error) } else if (collection.status === `ready`) { - // Post-readiness stream failure: sync adapter is giving up - hasHealthError = true + // Post-readiness stream failure: adapter does not resubscribe reportError(error, { final: true }) } else { console.error(`TrailBase subscription failed`, error) @@ -456,19 +463,12 @@ export function trailBaseCollectionOptions< if (collection.status === `loading`) { markError(error) } else if (collection.status === `ready`) { - // Post-readiness stream failure: sync adapter is giving up - hasHealthError = true + // Post-readiness stream failure: adapter does not resubscribe reportError(error, { final: true }) } return } - // Report Collection health recovery if listener settled successfully after a health error - if (hasHealthError && collection.status === `ready`) { - hasHealthError = false - reportHealthy() - } - // Lastly, start a periodic cleanup task that will be removed when the // reader closes. if (cancelled || !reader) return From 0ae399e48a62661d2e96b0d8ec2623d630a32ffd Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:33:30 -0600 Subject: [PATCH 016/106] docs(glossary): add Collection health terms Co-authored-by: Isaac --- docs/contributing/glossary.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 8d29eb5cba..9ce196dffc 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -58,6 +58,23 @@ production queues, caches, or semantic helpers merely to share their names. `source session`. Use `sync run` for the local Collection sync invocation and a provider-qualified `provider session` for a remote lifecycle. +## Collection health terms + +Collection status and Collection health are separate axes. Status decides +whether a sync run's initial sync may be shown; health reports what happens to +sync after that. Neither one changes the other. + +| Term | Meaning | Do not use as a synonym | +| --- | --- | --- | +| initial gate | The role of Collection status during a sync run's initial sync. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `markError()`. After Collection readiness in a sync run, the status no longer reflects sync failures. | Collection health or a live query's request errors. | +| initial error | The `markError()` failure that ends a sync run's initial gate in `error`, recorded with `phase: 'initial'`. It is final for its sync run: `markReady()` afterward throws, and only a restart can reach `ready`. | Health error or a retry. | +| Collection health | The sync adapter's report on a Collection's sync after Collection readiness in the current sync run, read through `collection.health` and `health:change`. Only `reportError()` and `reportHealthy()` change it. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | +| health error | One problem reported through `reportError()`, recorded as `CollectionErrorInfo` with `phase: 'sync'`. A health error that is not final means the sync adapter is still trying in the current sync run; `reportHealthy()` clears it. | Initial error or request error. | +| final health error | A health error with `final: true`: the sync adapter stopped trying in the current sync run. Only a restart clears it; `reportHealthy()` does not. | A Collection status of `error`. | +| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, recorded with `phase: 'request'` on that query's error tree node. The source Collection's health does not change. It clears when a later subset load for that demand fulfills or the demand retires. | Health error or request data. | +| error tree | The `errors` value of a live query or Effect: one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged. | Collection health or an error list. | +| error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. An include node summarizes all its per-row child Collections in `instances`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | + ## Persistence coordination terms | Term | Meaning | @@ -209,6 +226,9 @@ Use nouns for state and verbs for transitions: `cleaned-up`. Restart then **starts** a new sync run. - Truncate replay **replaces** source state inside the current sync run and any current dependent graph. +- A sync adapter **gives up** with `markError()` before Collection readiness. + After it, the adapter **reports** a health error and **reports healthy**. + A restart **clears** a final health error. Use `settled` for a promise that fulfilled or rejected. Use `complete` for named coverage, content, history, or a non-promise operation only when no stronger From 8c6cf14d41c0aeabb3d66f6bb075aeab48b84e17 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:33:36 -0600 Subject: [PATCH 017/106] docs(db): align health-channel wording with the glossary Co-authored-by: Isaac --- .../designs/source-failure-and-health/PLAN.md | 67 +++++++++---------- .../USER-DOCS-DRAFT.md | 12 ++-- packages/db/src/collection/lifecycle.ts | 22 +++--- packages/db/src/query/effect.ts | 20 +++--- packages/db/src/query/live/ARCHITECTURE.md | 20 +++--- .../db/tests/source-health-oracle.test.ts | 62 ++++++++--------- 6 files changed, 101 insertions(+), 102 deletions(-) diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 808395ac9c..729129f9d4 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -9,10 +9,10 @@ Branch: `feat/source-health-channel` (worktree ## 1. Decisions (maintainer, 2026-10-13) -1. **A retry that succeeds is a success.** While a source retries, it reports +1. **A retry that succeeds is a success.** While the sync adapter retries, it reports nothing. The Collection stays `loading`. (Already in #2127 for Electric.) -2. **`markError` is final for its sync attempt.** A source calls it only when - it gives up. The framework never heals an attempt: no `error` → `ready`. +2. **`markError` is final for its sync run.** A sync adapter calls it only when + it gives up. The framework never heals a sync run: no `error` → `ready`. 3. **Recovery is the app's job.** `cleanup()` then `preload()`, a refetch, or a user refresh starts a fresh attempt. The framework adds no retries. 4. **A final initial error reaches every consumer with its cause**: readiness @@ -42,12 +42,12 @@ names and shapes. - **Two axes.** Gate: `status` (`idle | loading | ready | error | cleaned-up`), decided only by an attempt's initial sync. Health: per - Collection, changed only by its source after the first ready. + Collection, changed only by its sync adapter after Collection readiness. - **Adapter API.** Sync params gain `reportError(error, { final })` and - `reportHealthy()`. `markError` is for giving up before the first ready. - `markError` after the first ready is treated as + `reportHealthy()`. `markError` is for giving up before Collection readiness. + `markError` after Collection readiness is treated as `reportError(error, { final: true })` with a development warning. - `markReady()` after `markError()` in one attempt throws. + `markReady()` after `markError()` in one sync run throws. - **Collection surface.** `collection.health` and a `health:change` event. - **Error info.** `{ phase: 'initial' | 'sync' | 'request', final, name, message, code?, at, cause }`. Everything except `cause` is JSON-safe. @@ -60,15 +60,15 @@ names and shapes. `instances: { total, inError }`. - Nested live queries recurse. - Healthy nodes are present with `isError: false`. -- **Final flag** (fracture F-b). `final: true` means the source stopped +- **Final flag** (fracture F-b). `final: true` means the sync adapter stopped trying; only a restart clears it. `reportHealthy()` cannot clear it. - **Request errors** (fracture F-c). A load for new demand that fails after - ready sets `phase: 'request'` on the live-query node that issued it. The - source stays healthy. It clears when that demand later settles or is + Collection readiness sets `phase: 'request'` on the live-query node that issued it. The + Collection stays healthy. It clears when that demand later settles or is released. - **Hooks.** Return `errors` beside `data`. `isError` keeps meaning "the initial gate is `error`". Docs lead with `errors.hasErrors` for stale data. -- **Effects.** `onHealthChange(errors)` after the first ready; no disposal. +- **Effects.** `onHealthChange(errors)` after Collection readiness; no disposal. Initial gate errors still dispose through `onSourceError`. - **SSR.** `errors` is not dehydrated; the client builds its own tree. @@ -77,11 +77,11 @@ names and shapes. ### 3.1 Core: `packages/db` - [ ] `collection/lifecycle.ts`: remove `error` → `ready` from the valid - transitions. `markReady()` after `markError()` in the same attempt + transitions. `markReady()` after `markError()` in the same sync run throws. Keep `error` → `cleaned-up` and `idle`. -- [ ] `collection/lifecycle.ts` / `sync.ts`: add health state, `reportError`, +- [ ] `collection/lifecycle.ts` / `sync.ts`: add Collection health state, `reportError`, `reportHealthy`, the `final` rule, and reset on cleanup and restart. - Scope the callbacks to the sync-run epoch so a stale attempt cannot + Scope the callbacks to the sync-run epoch so a stale sync run cannot report. - [ ] `collection/events.ts`: add `health:change` to `AllCollectionEvents`. Define the payload (previous, current) and the ordering: same tick, @@ -92,11 +92,11 @@ names and shapes. - [ ] `markError` after the first ready: forward to `reportError(error, { final: true })` and warn in development. - [ ] `query/live/collection-config-builder.ts`: - - [ ] a source entering `error` before the live query's first ready stays + - [ ] a Collection entering `error` before the live query's initial-query readiness stays a gate error for every shape (root, join, include alike); - [ ] remove the `erroredSourceIds` recovery path (line ~1131) and its status-based healing; - - [ ] post-ready source health does not change the live query's status or + - [ ] post-readiness Collection health does not change the live query's status or rows; - [ ] build and maintain the `errors` tree: per-alias nodes, include roll-ups, nested live-query relay, and request errors; @@ -106,8 +106,7 @@ names and shapes. - [ ] `collection/subscription.ts`: keep `lastError` and `loadSubset:error` for every rejected load (#2129 review C8). Do not detach demand for recovery. -- [ ] `query/effect.ts`: `onHealthChange`; no disposal after the first - ready. +- [ ] `query/effect.ts`: `onHealthChange`; no disposal after Collection readiness. - [ ] `live-query-adapter.ts` and `live-query-observer.ts`: add `errors` to the snapshot and status flags, so every framework adapter gets it from one place. Keep `isError` as the gate. @@ -121,26 +120,26 @@ names and shapes. - [ ] **Electric** (`electric-db-collection/src/electric.ts`, 9 call sites): - - [ ] post-ready `markError` at `:2628` and `:2708` becomes `reportError` + - [ ] post-readiness `markError` at `:2628` and `:2708` becomes `reportError` (`final` per site); - [ ] `reportHealthy()` after the next successful up-to-date following a problem; - - [ ] the SDK give-up path: before the first ready, `markError`; after it, + - [ ] the SDK give-up path: before Collection readiness, `markError`; after it, `reportError(..., { final: true })`. This extends #2127's E1 fix. - [ ] **query-db** (`query-db-collection/src/query.ts`, 6 sites): - - [ ] a failed refetch after ready calls `reportError`; a successful + - [ ] a failed refetch after readiness calls `reportError`; a successful result after a problem calls `reportHealthy()`; - [ ] `:2556`: no `markReady()` when the status is `error` (V1); - [ ] keep `utils.lastError`, `utils.isError`, and `utils.errorCount`, and - state how they relate to health (assay compromise). + state how they relate to Collection health (assay compromise). - [ ] **PowerSync** (`powersync-db-collection/src/powersync.ts`, 6 sites): a failed tracking rebuild at `:826` calls `reportError`; a successful - rebuild at `:813` calls `reportHealthy()` instead of `markReady()` (V2). + rebuild at `:813` calls `reportHealthy()` instead of `markReady()` (V2, post-readiness health). - [ ] **TrailBase** (`trailbase-db-collection/src/trailbase.ts`, 4 sites): - post-ready stream failures call `reportError`. Check them against the + post-readiness stream failures call `reportError`. Check them against the #2126 fix. - [ ] A stream that ends, whether it closes or errors, reports - `reportError(error, { final: true })` after the first ready. The + `reportError(error, { final: true })` after Collection readiness. The adapter does not resubscribe. The `trailbase` SDK (0.10.0) has no reconnect or resume: `subscribe` is one `fetch` stream, and `subscribeWs` closes without retrying. A correct reconnect would @@ -149,12 +148,12 @@ names and shapes. - [ ] Update the stream-end lane in `tests/lifecycle-oracle.property.test.ts` (added in #2126) from "rows stay, later changes are not delivered" to also expect a final health error. -- [ ] **RxDB** (`rxdb-db-collection/src/rxdb.ts`, 3 sites): post-ready +- [ ] **RxDB** (`rxdb-db-collection/src/rxdb.ts`, 3 sites): post-readiness failures call `reportError`. Observe what happens to pending loads on a final error (evidence gap V10). - [ ] **Persistence** (`db-sqlite-persistence-core/src/persisted.ts`, 19 sites): - - [ ] forward upstream health; keep local storage failures on + - [ ] forward upstream Collection health; keep local storage failures on `persistedError`; - [ ] follower tabs learn the leader's upstream health through the coordinator (open question); @@ -189,7 +188,7 @@ names and shapes. model-d-source-error-grammar.patch` (the C4, C5, and C6 grammar dimensions: truncate during the error, ready → error, exact acquisitions, and every `source-ready` position). Under the new law, - `source-ready` after `source-error` in one attempt is illegal, so those + `source-ready` after `source-error` in one sync run is illegal, so those histories become "restart, then recover". - [ ] Shared framework conformance suite (`packages/db/tests/conformance/`): a health scenario, so all five drivers check `errors` the same way. @@ -211,23 +210,23 @@ names and shapes. recovery) and line ~534 (a refetch recovers). - [ ] `docs/guides/collection-options-creator.md`: the adapter rules from `USER-DOCS-DRAFT.md` (stay silent while retrying; `markError` only when - giving up before ready; `reportError` and `reportHealthy` after ready; + giving up before Collection readiness; `reportError` and `reportHealthy` after readiness; never drop rows). - [ ] `docs/guides/live-queries.md`: the `errors` tree and the `isError` / `errors.hasErrors` callout. - [ ] `docs/guides/sqlite-persistence.md`, `docs/guides/offline-transactions.md`: - upstream health against `persistedError`. + upstream Collection health against `persistedError`. - [ ] `docs/errors.md`: the `markReady`-after-`markError` contract error, and any new error code. - [ ] `docs/collections/electric-collection.md`: `onError` retries are - silent; a give-up is final; post-ready health. + silent; a give-up is final; post-readiness health. - [ ] `docs/collections/local-storage-collection.md`: check its error-state wording. - [ ] `docs/collections/query-collection.md` (if present): how `utils` relate to health; recovery by restart. - [ ] Package skills: `packages/db/skills/db-core/custom-adapter/SKILL.md` (remove "later successful work in the same sync run can call - `markReady()` to recover", line ~349), and the React, Vue, Solid, and + `markReady()` to recover", line ~349, since recovery now requires a restart), and the React, Vue, Solid, and Angular skill files. Confirm with the maintainer that these package skills are in scope. - [ ] Package READMEs that mention `isError` (for example @@ -244,7 +243,7 @@ names and shapes. changes. Include a migration guide: - an initial error no longer heals; recover with `cleanup()` and `preload()`; - - after the first ready, failures no longer move a Collection or a live + - after Collection readiness, failures no longer move a Collection or a live query to `error`; read `errors`; - adapter authors: `reportError` and `reportHealthy`; - `markReady()` after `markError()` now throws. @@ -284,4 +283,4 @@ names and shapes. yes. It is in 3.1. - #2129 C4, C5, and C6 (Model D grammar gaps) carry over in 3.4. - #2127 E1 (an SDK give-up must be reported) is fixed in #2127 for the - initial gate. The post-ready half is in 3.2. + initial gate. The post-readiness half is in 3.2. diff --git a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md index 4b3216cc8a..e948938204 100644 --- a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md +++ b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md @@ -11,13 +11,13 @@ A Collection answers two separate questions: | Question | Where to look | Changes when | | --- | --- | --- | -| Can I show this data yet? | `status`, `isReady`, `isError` | Only during the initial load of a sync attempt | +| Can I show this data yet? | `status`, `isReady`, `isError` | Only during the initial load of a sync run | | Is this data still being kept up to date? | `errors` (the error tree) | Any time a source reports a problem or recovers | ### The initial load Until a Collection has its first usable snapshot, `status` is `loading`. If the -source keeps retrying, it stays `loading`. If the source gives up, `status` +source keeps retrying, it stays `loading`. If the sync adapter gives up, `status` becomes `error`, `isError` is `true`, `preload()` rejects with the original cause, and every live query that depends on it is in error too. @@ -134,7 +134,7 @@ For the query above, while the users sync is disconnected: Phases: -- `initial`: the source gave up before its first ready. `status` is `error`. +- `initial`: the source gave up before Collection readiness. `status` is `error`. - `sync`: the source reported a problem after ready. `final: false` means it is still trying; `final: true` means it stopped, and only a restart clears it. - `request`: loading data for this query failed while its source stayed @@ -211,21 +211,21 @@ The rules: 1. While you are still retrying the initial load, call nothing. The Collection stays `loading`. -2. Call `markError(error)` only when you give up before the first ready. Do +2. Call `markError(error)` only when you give up before Collection readiness. Do not call `markReady()` after that; restart is the app's job. 3. After the first ready, call `reportError(error, { final })` for problems and `reportHealthy()` when sync works again. Never move a ready Collection to `error`. 4. Do not discard rows because sync failed. -`markError(error)` after the first ready still works during migration. It is +`markError(error)` after Collection readiness still works during migration. It is treated as `reportError(error, { final: true })` and logs a development warning. ### Migration notes for the built-in adapters - **Electric:** a stream error that `onError` retries is not reported. When the - stream gives up after the first ready, it reports `final: true` instead of + stream gives up after Collection readiness, it reports `final: true` instead of moving the Collection to `error`. - **PowerSync:** a failed tracking rebuild reports `reportError`; a successful rebuild reports `reportHealthy()` instead of `markReady()`. diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index a316353950..a29ea5c0d2 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -57,12 +57,12 @@ export class CollectionLifecycleManager< public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null private syncError: unknown - // Health is metadata after the first ready. A source's own report and a + // Health is metadata after Collection readiness. The sync adapter's own report and a // live query's request failures are separate, so settling a request cannot - // clear a source problem. + // clear the sync adapter's problem. private sourceProblem: CollectionErrorInfo | null = null // Error tree detail for the initial gate error, kept so readers see the - // same object until the attempt ends. + // same object until the sync run ends. private initialProblem: CollectionErrorInfo | null = null private requestProblem: CollectionErrorInfo | null = null private cleanupConfig: () => void @@ -118,7 +118,7 @@ export class CollectionLifecycleManager< // A ready Collection moves to `error` only through crash(); a source's // later failure is health metadata. ready: [`cleaned-up`, `error`], - // An initial error is final for its sync attempt. + // An initial error is final for its sync run. error: [`cleaned-up`, `idle`], 'cleaned-up': [`loading`, `error`], } @@ -251,15 +251,15 @@ export class CollectionLifecycleManager< } /** - * The source gave up. Before the first ready this fails the initial sync - * of the attempt; after it, the problem is final health metadata and the + * The sync adapter gave up. Before Collection readiness this fails the initial sync + * of the sync run; after it, the problem is final health metadata and the * data stays. */ public markError(error?: unknown): void { if (this.status === `ready`) { if (devBuild() && process.env.NODE_ENV !== `production`) { console.warn( - `[TanStack DB] Collection "${this.id}" called markError() after its first ready. ` + + `[TanStack DB] Collection "${this.id}" called markError() after Collection readiness. ` + `Call reportError(error, { final: true }) instead; the status stays ready.`, ) } @@ -275,7 +275,7 @@ export class CollectionLifecycleManager< * unpublished rows stops being usable. */ public crash(error?: unknown): void { - // The first error is final for the attempt. + // The first error is final for the sync run. if (this.status === `error`) return this.validateStatusTransition(this.status, `error`) this.syncError = error @@ -293,7 +293,7 @@ export class CollectionLifecycleManager< return this.status === `error` ? this.initialProblem : null } - /** A source's report after its first ready. Status and rows do not change. */ + /** The sync adapter's report after Collection readiness. Status and rows do not change. */ public reportError(error: unknown, options?: { final?: boolean }): void { this.assertReportable(`reportError`) // A final problem stays until a restart. @@ -310,7 +310,7 @@ export class CollectionLifecycleManager< this.events.emitHealthChange() } - /** @internal A live query's load failed after its first ready. */ + /** @internal A live query's load failed after Collection readiness. */ public reportRequestError(error: unknown): void { this.requestProblem = describeProblem(error, `request`, false) this.events.emitHealthChange() @@ -327,7 +327,7 @@ export class CollectionLifecycleManager< if (this.status !== `ready`) { throw new CollectionStateError( devBuild() && process.env.NODE_ENV !== `production` - ? `${operation}() reports a problem after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up.` + ? `${operation}() reports a problem after Collection readiness, but Collection "${this.id}" is ${this.status}. Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up.` : codedMessage(243, { operation, id: this.id, status: this.status }), ) } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 4954828192..f3e660213a 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -135,15 +135,15 @@ export interface EffectConfig< onError?: (error: Error, event: DeltaEvent) => void /** - * Called when a source collection fails before the effect's first ready, or + * Called when a Collection fails before the effect's initial-query readiness, or * is cleaned up. The effect is automatically disposed after this callback * fires. If not provided, the error is logged to console.error. */ onSourceError?: (error: Error) => void /** - * Called after the effect's first ready whenever a source's status or health - * changes. A source problem after the first ready does not dispose the + * Called after the effect's initial-query readiness whenever a Collection's status or health + * changes. A Collection health problem after initial-query readiness does not dispose the * effect; read the error tree to decide what to do. */ onHealthChange?: (errors: ErrorTreeNode) => void @@ -471,9 +471,9 @@ class EffectPipelineRunner { // skipInitial state private readonly skipInitial: boolean private initialLoadComplete = false - // The Effect's gate, as a live query's first ready: latched when every - // initial load has settled, with or without skipInitial. After it, a - // source problem is health, not disposal. + // The Effect's initial gate: latched when every + // initial load has settled, with or without skipInitial. After it, + // a Collection health problem does not dispose the Effect. private reachedReady = false private readonly failedRequests = new Set() private requestProblem: CollectionErrorInfo | null = null @@ -742,8 +742,8 @@ class EffectPipelineRunner { const { status } = event - // A source failure before the effect's first ready ends the effect. - // After it, the failure is health and the effect keeps running. + // A Collection failure before the effect's initial gate ends the effect. + // After it, the failure is health metadata and the effect keeps running. if (status === `error` && this.reachedReady) { this.reportHealth() return @@ -1072,7 +1072,7 @@ class EffectPipelineRunner { ) } - /** Latch the Effect's gate, its first ready, and report whether it has passed. */ + /** Latch the Effect's initial gate and report whether it has been reached. */ private latchReady(): boolean { if (!this.reachedReady && this.canCompleteInitialLoad()) this.reachedReady = true @@ -1211,7 +1211,7 @@ class EffectPipelineRunner { this.disposed = true this.subscribedToAllCollections = false - // Release every source in one attempt; the first failure wins after the + // Release every source in one sync run; the first failure wins after the // peers finish. A reentrant dispose returns at the guard above, so this // call still owns each release exactly once. try { diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 17b73870ec..d8ba6a0121 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -825,7 +825,7 @@ live query itself. This differs from a source truncate, which keeps the live query active behind its replay publication barrier. An initial sync error also leaves newly requested demand detached, even when -the adapter has installed a loader. The error is final for its sync attempt: +the adapter has installed a loader. The error is final for its sync run: `markReady()` in the same sync run is a contract violation and throws, and only a restart, which begins a new sync run, acquires that demand. Releasing it before the restart creates no physical acquisition or unload. Queued @@ -1580,17 +1580,17 @@ create recursive Collection machinery. counts as a preload. A deferring live query keeps its sources from garbage collection but is not counted in their `subscriberCount` or `subscribers:change`, which adapters read as a request for live data. -16. **Final initial errors and source health:** a sync attempt's initial - error is final for that attempt; a Collection moves from `error` to - `ready` only through a restart. Before a live query's first ready, a - source that fails fails the query's attempt, for every shape. After its - first ready, a source's later problem is health metadata: the query keeps +16. **Final initial errors and Collection health:** a sync run's initial + error is final for that sync run; a Collection moves from `error` to + `ready` only through a restart. Before a live query's initial-query readiness, a + Collection that fails fails the query's sync run, for every shape. After its + initial-query readiness, a Collection's later problem is health metadata: the query keeps its status and rows, and its error tree reports the problem. A lazy load - or a root source's load that fails after the first ready is a request + or a root Collection's load that fails after initial-query readiness is a request error on the query's node. A lazy demand's error clears when that demand - settles or is released; a root source's error clears when a later load of - that source settles. Query failures and contract violations, such as a - graph failure or a source cleaned up under the query, still end the + settles or is released; a root Collection's error clears when a later load of + that Collection settles. Query failures and contract violations, such as a + graph failure or a Collection cleaned up under the query, still end the query. So does an IndexedDB connection closure: it ends read and admission authority, which no health report can restore. An Effect has the same two axes, with its gate at its settled initial loads: a load diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 49fd3a629d..e635fb4e3e 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -4,16 +4,16 @@ * A Collection answers two questions on two separate axes: * * - **The gate (`status`)** says whether the data can be shown yet. It is - * decided only by the initial sync of a sync attempt. While a source retries - * it stays `loading`. When the source gives up before its first ready - * (`markError`), the attempt fails for good: the status is `error`, and a - * later `markReady()` in the same attempt is a contract violation that + * decided only by the initial sync of a sync run. While the sync adapter retries + * it stays `loading`. When the sync adapter gives up before Collection readiness + * (`markError`), the sync run fails for good: the status is `error`, and a + * later `markReady()` in the same sync run is a contract violation that * throws. Only a restart (`cleanup()`, then a new preload or subscriber) - * begins a new attempt. + * begins a new sync run. * - **Health** says whether the data is still kept up to date. It is metadata - * after the first ready: `reportError(error, { final })` records a problem, - * `reportHealthy()` clears a non-final one, and `markError()` after ready is - * a final problem. The status and the rows do not change. Only the source + * after Collection readiness: `reportError(error, { final })` records a problem, + * `reportHealthy()` clears a non-final one, and `markError()` after Collection readiness is + * a final problem. The status and the rows do not change. Only the sync adapter * clears its problem, and nothing clears a final problem except a restart. * * A live query exposes both axes for everything it depends on through @@ -38,29 +38,29 @@ * IndexedDB oracles own that case. * * A live query's own gate follows its sources: it becomes ready when every - * source is ready, and a source that fails before the query's first ready - * fails the query's attempt, whatever the query's shape. After the query's - * first ready, its sources can only report health, and the query keeps its + * Collection is ready, and a Collection that fails before the query's initial-query readiness + * fails the query's sync run, whatever the query's shape. After the query's + * initial-query readiness, its sources can only report health, and the query keeps its * status and rows. * * ## The model * - * The model keeps, for each source, its gate and its health problem (none, + * The model keeps, for each Collection, its gate and its health problem (none, * non-final, or final), with a serial for each reported problem. It derives - * each live query's gate from its sources, using only the rules above, and + * each live query's gate from its Collections, using only the rules above, and * the include's parent rows from whether `B` synced its row while the - * attempt was running. It builds the expected tree from those facts. It does + * sync run was running. It builds the expected tree from those facts. It does * not read production state. * * ## The history grammar * - * Steps act on source `A` or `B`: `ready` writes the source's row and calls + * Steps act on Collection `A` or `B`: `ready` writes the Collection's row and calls * `markReady()`; `fail` calls `markError()`; `report` and `reportFinal` call * `reportError()`; `healthy` calls `reportHealthy()`. `restart` cleans up the - * live query and every source, then preloads the live query again. Every step + * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw - * for `markReady()` after `markError()`, and for a health report before the - * first ready. Six shapes cross the grammar: a direct source, a join, a + * for `markReady()` after `markError()`, and for a health report before + * Collection readiness. Six shapes cross the grammar: a direct Collection, a join, a * self-join of one Collection under two aliases, a nested live query, an * include child that materializes a Collection per parent row, and a pooled * live query over a nested live query. @@ -75,23 +75,23 @@ * that no `health:change` shows a status that `status:change` has not * announced, and that each `status:change` is followed at once by * `health:change`. When the expected tree changed, the last event must show - * the current tree. Request lanes check that a lazy load or a root source's - * load that fails after the first ready is a request error on the query's + * the current tree. Request lanes check that a lazy load or a root Collection's + * load that fails after initial-query readiness is a request error on the query's * node, and that it clears. Effect lanes check the Effect's gate and its * request errors after the gate. * * ## Limits * - * Sources are eager, so a live query cannot become ready while a source is - * still loading. The on-demand case, where a source can fail its initial sync + * Collections are eager, so a live query cannot become ready while a Collection is + * still loading. The on-demand case, where a Collection can fail its initial sync * after the live query is ready, is not generated here. Per-row child * Collections are rolled up, not nodes, and no grammar step fails one; the * include-instances lane fails one and checks the roll-up, the relay, and * that two includes under one alias count separately. An Effect's request - * error clears on a later successful load of the same source; release of + * error clears on a later successful load of the same Collection; release of * only the failed demand is not exercised. Pooled histories do not restart: a pooled view ends for good when - * its source is cleaned up. The ordering check reads the observed query - * only, not its sources. + * its Collection is cleaned up. The ordering check reads the observed query + * only, not its Collections. */ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' @@ -212,7 +212,7 @@ function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { else source.gate = `ready` break case `fail`: - // Before the first ready the attempt fails; after it, a final problem. + // Before Collection readiness the sync run fails; after it, a final problem. if (source.gate !== `ready`) source.gate = `error` else if (!source.problem?.final) source.problem = { final: true, serial: ++model.serial } @@ -686,7 +686,7 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { - 'a non-final problem clears when the source reports healthy': [ + 'a non-final problem clears when the sync adapter reports healthy': [ on(`ready`, `A`), on(`ready`, `B`), on(`report`, `A`), @@ -701,24 +701,24 @@ const namedHistories: Record> = { on(`ready`, `A`), on(`ready`, `B`), ], - 'markError after the first ready is final health': [ + 'markError after Collection readiness is final health': [ on(`ready`, `A`), on(`ready`, `B`), on(`fail`, `A`), on(`healthy`, `A`), ], - 'an initial error is final for its attempt': [ + 'an initial error is final for its sync run': [ on(`fail`, `A`), on(`ready`, `A`), on(`ready`, `B`), ], - 'a restart recovers an attempt that failed': [ + 'a restart recovers a sync run that failed': [ on(`fail`, `A`), restart, on(`ready`, `A`), on(`ready`, `B`), ], - 'a health report before the first ready throws': [ + 'a health report before Collection readiness throws': [ on(`report`, `A`), on(`healthy`, `B`), on(`ready`, `A`), From 4a66e2a74ac24aa3c7d8ba028f897578396f29f4 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:34:56 -0600 Subject: [PATCH 018/106] fix(hooks): implement errors runtime and add conformance oracle scenarios Complete the incomplete hook types commit by implementing runtime assignment of the errors tree in all frameworks (Solid, Svelte). React Suspense and conformance test interface also updated. - Solid: createSignal for errors, sync from observer snapshot - Svelte: $state for errors, sync from observer snapshot - React Suspense: return errors field from hook - ConformanceResult: add errors field (post-readiness health tree) - Conformance suite: add health scenarios for error-status, identity, and hasErrors New scenarios: - health-initial-gate-error: verifies errors tree in initial gate failure - health-errors-stable-identity: verifies object identity for memoization Total scenarios: 35 (up from 32). Co-authored-by: Isaac --- packages/db/tests/conformance/contract.ts | 2 + packages/db/tests/conformance/suite.ts | 61 ++++++++++++++++++- packages/react-db/src/useLiveSuspenseQuery.ts | 1 + packages/solid-db/src/useLiveQuery.ts | 11 ++++ packages/svelte-db/src/useLiveQuery.svelte.ts | 5 ++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 7e28c9cc7f..acaceccad7 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -113,6 +113,8 @@ export interface ConformanceResult { persistedError?: unknown isError: boolean isEnabled: boolean + /** Error tree for the query and its dependencies (post-readiness health). */ + errors?: any } /** A query-builder callback, e.g. `(q) => q.from({ items: source.collection })`. */ diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index a3791fc35d..530e5e3b9e 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1054,13 +1054,70 @@ export function runSuite(rawDriver: LiveQueryDriver) { // boundary to catch), rather than exposing a readable flag. expect(() => h.current()).toThrow() } else { - expect(h.current().status).toBe(`error`) + const result = h.current() + expect(result.status).toBe(`error`) + expect(result.isError).toBe(true) + // Health law: initial gate error surfaces in errors tree + expect(result.errors).toBeDefined() + expect(result.errors?.hasErrors).toBe(true) + } + h.unmount() + }, + ) + + scenario( + `health-initial-gate-error`, + `initial-gate error surfaces in errors tree hasErrors`, + async () => { + const source = driver.makeErrorSource() + const h = driver.mountCollection(source.collection) + await h.flush() + + if (driver.errorSurface !== `throw`) { + // Initial-gate error: isError true and errors tree reflects it expect(h.current().isError).toBe(true) + expect(h.current().errors?.hasErrors).toBe(true) + // Tree should have root node when collection itself is in error + expect(h.current().errors?.isError).toBe(true) } h.unmount() }, ) + scenario( + `health-errors-stable-identity`, + `errors tree keeps stable identity when unchanged across renders`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .select(({ items }: any) => items), + ) + await h.flush() + + // Ready state: errors should be defined but hasErrors false + expect(h.current().errors).toBeDefined() + expect(h.current().errors?.hasErrors).toBe(false) + + const firstErrors = h.current().errors + // Trigger a render without changing data + await h.flush() + + // Stable identity: same object when unchanged + expect(h.current().errors).toBe(firstErrors) + + // Mutate data + source.insert({ id: `999`, name: `New`, age: 50, team: `z` }) + await h.flush() + + // After data change, errors object might or might not be new, + // but hasErrors should still be false (no error) + expect(h.current().errors?.hasErrors).toBe(false) + h.unmount() + }, + ) + // ---- tail: universal expected-fail --------------------------- scenario( @@ -1098,7 +1155,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(32) + expect(registry.size).toBe(35) }) }) } diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 29c05f7692..95295e8e5b 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -361,5 +361,6 @@ export function useLiveSuspenseQuery( state: result.state, data: result.data, collection: result.collection, + errors: result.errors, } } diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index f456caf7be..1271f949c8 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -378,6 +378,7 @@ export function useLiveQuery( const [persistedStatus, setLocalStatus] = createSignal(`unavailable`) const [persistedError, setLocalError] = createSignal(undefined) + const [errors, setErrors] = createSignal(undefined) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( @@ -428,6 +429,7 @@ export function useLiveQuery( return [] } setStatus(currentCollection.status) + setErrors(currentCollection.errors) try { const observer = createLiveQueryObserver(currentCollection) localWaitObserver = observer @@ -453,6 +455,7 @@ export function useLiveQuery( } syncDataFromCollection(currentCollection) setStatus(currentCollection.status) + setErrors(currentCollection.errors) }) return data }, @@ -469,6 +472,7 @@ export function useLiveQuery( setStatus(`disabled` as const) setLocalStatus(`unavailable`) setLocalError(undefined) + setErrors(undefined) state.clear() rowsByKey.clear() rowsCollection = undefined @@ -517,6 +521,7 @@ export function useLiveQuery( setStatus(snapshot.status) setLocalStatus(snapshot.persistedStatus) setLocalError(snapshot.persistedError) + setErrors(snapshot.errors) }) }, ) @@ -529,6 +534,7 @@ export function useLiveQuery( setStatus(snapshot.status) setLocalStatus(snapshot.persistedStatus) setLocalError(snapshot.persistedError) + setErrors(snapshot.errors) }) onCleanup(() => { @@ -611,6 +617,11 @@ export function useLiveQuery( return status() === `cleaned-up` }, }, + errors: { + get() { + return errors() + }, + }, }) return getData } diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 86da498000..99f0e69e82 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -463,6 +463,7 @@ export function useLiveQuery( let status = $state(initialSnapshot.status) let persistedStatus = $state(initialSnapshot.persistedStatus) let persistedError = $state.raw(initialSnapshot.persistedError) + let errors = $state.raw(initialSnapshot.errors) const syncFromObserver = ( observer: LiveQueryObserver, @@ -472,6 +473,7 @@ export function useLiveQuery( status = snapshot.status as CollectionStatus persistedStatus = snapshot.persistedStatus persistedError = snapshot.persistedError + errors = snapshot.errors untrack(() => { if (changes && changes.length > 0) { for (const change of changes) { @@ -574,5 +576,8 @@ export function useLiveQuery( get isCleanedUp() { return status === `cleaned-up` }, + get errors() { + return errors + }, } } From f143b4341680bf6f43a8c27d6c57517c55d6dba0 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:38:56 -0600 Subject: [PATCH 019/106] docs(db): regenerate error messages after the glossary wording The previous commit changed two health-channel messages without updating the error-site fixtures or the generated error page. Co-authored-by: Isaac --- docs/errors.md | 2 +- packages/db/tests/fixtures/development-only-messages.json | 2 +- packages/db/tests/fixtures/error-site-messages.json | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 80923754d5..f28491ecbf 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -2457,7 +2457,7 @@ LocalStorage sync belongs to its options Collection. Development builds show this message, with `${...}` replaced by values: ```text -${operation}() reports a problem after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up. +${operation}() reports a problem after Collection readiness, but Collection "${this.id}" is ${this.status}. Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up. ``` diff --git a/packages/db/tests/fixtures/development-only-messages.json b/packages/db/tests/fixtures/development-only-messages.json index 653147b0ce..3bffee9e1a 100644 --- a/packages/db/tests/fixtures/development-only-messages.json +++ b/packages/db/tests/fixtures/development-only-messages.json @@ -3,7 +3,7 @@ "file": "collection/lifecycle.ts", "literals": [ "[TanStack DB] Collection \"", - "\" called markError() after its first ready. ", + "\" called markError() after Collection readiness. ", "Call reportError(error, { final: true }) instead; the status stays ready." ] }, diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index bc27acac23..208102fd53 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -926,11 +926,11 @@ }, "243": { "file": "collection/lifecycle.ts", - "template": "${operation}() reports a problem after the first ready, but Collection \"${this.id}\" is ${this.status}. Before the first ready, retry without reporting, or call markError() when the source gives up.", + "template": "${operation}() reports a problem after Collection readiness, but Collection \"${this.id}\" is ${this.status}. Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up.", "literals": [ - "() reports a problem after the first ready, but Collection \"", + "() reports a problem after Collection readiness, but Collection \"", "\" is ", - ". Before the first ready, retry without reporting, or call markError() when the source gives up." + ". Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up." ] }, "244": { From f742b7198a1b47d53b35a437aeca1e8b5a264d96 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:42:07 -0600 Subject: [PATCH 020/106] fix(conformance): add errors field to all driver result mappers Frameworks must expose the errors tree from live-query hooks in conformance tests. Update all five driver implementations (React, Vue, Solid, Angular) to map the errors field from the hook result to ConformanceResult. Also fix React's error-recovery test to reflect the new health law: after initial readiness, markError causes a health error (in errors tree), not a status change to error. Status remains the initial gate only. Co-authored-by: Isaac --- packages/angular-db/tests/conformance.test.ts | 1 + packages/react-db/tests/conformance.test.tsx | 1 + packages/react-db/tests/useLiveQuery.test.tsx | 5 ++++- packages/solid-db/tests/conformance.test.tsx | 1 + packages/vue-db/tests/conformance.test.ts | 1 + 5 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 3ec3f9d918..4c6edd6af6 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -154,6 +154,7 @@ function makeHandle(result: any, destroy: () => void): LiveQueryHandle { isError: result.isError(), // angular-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result.status() !== `disabled`, + errors: result.errors(), }) }, flush: settle, diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 1454151942..c05f031ed4 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -182,6 +182,7 @@ function makeHandle(hook: RenderHookResult) { // Read react-db's real `isEnabled` field so the suite catches a broken // one (deriving from status would mask it). isEnabled: r?.isEnabled, + errors: r?.errors, }) }, async flush() { diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 6d09e4152f..13c5776c12 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1024,7 +1024,10 @@ describe(`Query Collections`, () => { expect(result.current.data[0]?.text).toBe(`Todo 1`) act(() => failSource()) - await waitFor(() => expect(result.current.isError).toBe(true)) + // After initial readiness, markError becomes a health error, not a status change. + // The status remains ready and data is stale but still visible. + // This is the new health law: status only reflects the initial gate. + // We don't expect status to change to error. await act(async () => { await todosQuery.cleanup() diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index bc631913ca..3462f75dd0 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -147,6 +147,7 @@ function makeHandle( isError: result?.isError, // solid-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result?.status !== `disabled`, + errors: result?.errors, }) }, flush: settle, diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 0b28afe8c6..3e0af8060a 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -143,6 +143,7 @@ function makeHandle(result: any, scope: ReturnType) { isError: result.isError?.value, // vue-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result.status?.value !== `disabled`, + errors: result.errors?.value, }) }, flush: settle, From cac01198873078c6d97aca6a9fb6b9dc122eddcc Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:43:44 -0600 Subject: [PATCH 021/106] fix: correct conformance scenario count (34 not 35) Added 2 new health scenarios to the 32 existing ones. Co-authored-by: Isaac --- packages/db/tests/conformance/suite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 530e5e3b9e..36af469817 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1155,7 +1155,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(35) + expect(registry.size).toBe(34) }) }) } From 381e07f411ba1569155c62e70e76fc6410a973a2 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:46:43 -0600 Subject: [PATCH 022/106] fix(angular): add errors to raw result type test The preserves raw result types test needs to include errors in the mock raw object to match the ConformanceResult interface. Co-authored-by: Isaac --- packages/angular-db/tests/conformance.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 4c6edd6af6..054f0deca1 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -382,6 +382,7 @@ it(`preserves raw result types through the actual driver reader`, () => { persistedError: undefined, isError: false, isEnabled: false, + errors: undefined, } const result = Object.fromEntries( Object.keys(raw).map((key) => [key, () => raw[key]]), From b4bbde5e63874d0d186b82b4acab2a2973a07dab Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:47:47 -0600 Subject: [PATCH 023/106] fix(adapters): improve health reporting finality logic and test coverage - PowerSync: fix rebuild failure to only report if status is ready (not else) - query-db: enhance comments explaining finality: observers retry on interval/reconnect/manual refetch - query-db test: fix 'recovers a dependent' to use cleanup+preload pattern for recovery - PowerSync test: update 'reports a source error' to check health.isError instead of status Addresses PLAN 3.2 finality requirements: post-readiness failures use final:false because query observers will retry and PowerSync may retry on new demand. Co-authored-by: Isaac --- .../powersync-db-collection/src/powersync.ts | 6 ++-- .../tests/on-demand-sync.test.ts | 6 +++- packages/query-db-collection/src/query.ts | 7 ++-- .../load-subset-lifecycle-oracle.test.ts | 36 +++++++++++++++++-- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 2e8dccd33b..638a4bcf3e 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -846,10 +846,12 @@ function createPowerSyncCollectionConfig< if (collection.status === `loading`) { // Initial load failure is final for the attempt markError(error) - } else { - // Post-ready failures are reported as health issues + } else if (collection.status === `ready`) { + // Post-ready failures are reported as health issues (not final; + // the adapter may retry on new demand or automatic reconciliation) reportError(error, { final: false }) } + // Other statuses (error, idle, cleaned-up) don't report } throw error }) diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 6c3926542f..084af42807 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -3246,7 +3246,11 @@ describe(`On-Demand Sync Mode`, () => { collection._sync.loadSubset({ where: categoryEquals(`clothing`) }), ), ).rejects.toBe(failure) - expect(collection.status).toBe(`error`) + // After the first ready, failures don't change status. Instead, they're + // reported as health errors. Status stays ready; data stays available. + expect(collection.status).toBe(`ready`) + expect(collection.health.isError).toBe(true) + expect(collection.health.error?.message).toMatch(`trigger installation failed`) } finally { await collection.cleanup() } diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index de11c102a6..cb7cbb6a31 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -2801,7 +2801,9 @@ export function queryCollectionOptions( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) - // Report post-ready failures as health issues (not final) + // Report post-ready failures as health issues. Not final because query + // observers will retry via refetchInterval, refetchOnWindowFocus, or + // manual refetch() calls; the observer may succeed on the next attempt. if (collection.status === `ready`) { reportError(error, { final: false }) } @@ -3181,7 +3183,8 @@ export function queryCollectionOptions( if (collection.status === `loading`) { markError(result.error) } else if (collection.status === `ready`) { - // Report post-ready failures as health issues (not final) + // Report post-ready failures as health issues. Not final because + // observers will retry on interval/reconnect/manual refetch. reportError(result.error, { final: false }) } } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index 1eef409062..6d6303d77d 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -1203,10 +1203,25 @@ async function expectInitialQueryFailureStatus(): Promise { throw new TraceAssertionError(0, caught) } + // With the new design, an initial error is final for its sync attempt. + // clearError() triggers a refetch, but the status stays error until + // cleanup() + preload() starts a fresh sync attempt. + // First, note that the refetch will succeed and the data will be available + // in lastError tracking, but status stays error. await collection.utils.clearError() + // After a successful refetch from an error state, status stays error, + // but utils.lastError clears and we can see the data await vi.waitFor(() => { - expect(collection.status).toBe(`ready`) + expect(collection.utils.lastError).toBeUndefined() expect(collection.get(`recovered`)).toBeDefined() + }) + // To fully recover to ready status, cleanup and restart + await collection.cleanup() + await live.cleanup() + await collection.preload() + await live.preload() + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) expect(live.status).toBe(`ready`) expect(live.get(`recovered`)).toBeDefined() }) @@ -1315,13 +1330,30 @@ async function expectEveryFailedSourceToRecover(): Promise { await expect(preload).rejects.toThrow() expect(live.status).toBe(`error`) + // With the new design, recovery from initial errors requires cleanup() + preload(). + // After markError(), you cannot call markReady() in the same sync run. + // Instead, recovery is via cleanup() + preload() which starts a fresh sync attempt. + + // Restart left + await left.collection.cleanup() + const leftPreload = left.collection.preload() left.recover({ id: `shared` }) + await leftPreload expect(left.collection.status).toBe(`ready`) expect(right.collection.status).toBe(`error`) expect(live.status).toBe(`error`) + // Restart right + await right.collection.cleanup() + const rightPreload = right.collection.preload() right.recover({ id: `shared` }) - await expect(live.preload()).resolves.toBeUndefined() + await rightPreload + expect(right.collection.status).toBe(`ready`) + + // Live query should now be ready after both sources recover + await live.cleanup() + const livePreload2 = live.preload() + await livePreload2 expect(live.status).toBe(`ready`) expect(live.toArray.map((row) => row.id)).toEqual([`shared`]) } finally { From 4b36cbecc21664b3d310e65a702102df3a3c20e6 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:48:22 -0600 Subject: [PATCH 024/106] fix(db-sqlite-persistence-core): crash Collection on terminal local durability failures Stage 1 health law requires terminal local persistence failures (unrecoverable store failures like SQLITE_IOERR_FSYNC) to crash the Collection and stop admitting work, following the IndexedDB closure precedent set at packages/db/src/indexed-db.ts line 685. Before this change, terminal failures were routed through markError(), which at readiness converts to reportError() health metadata instead of transitioning status to 'error'. Terminal local failures now call crash() directly, keeping status as 'error' even after Collection readiness. Upstream (wrapped sync adapter) failures after readiness remain health metadata as before. Update durability lane oracle prose to document this law. Co-authored-by: Isaac --- packages/db-sqlite-persistence-core/src/persisted.ts | 12 +++++++++++- .../tests/persisted-oracle.test.ts | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 4b61074912..0de4c89cb3 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -2011,7 +2011,17 @@ class PersistedCollectionRuntime< this.remoteEnsureRetryTimer = null } this.rejectQueuedHydrationTransactions(error) - this.reportSyncError(error) + // Terminal local persistence failures crash the Collection, like IndexedDB + // closure does. This stops admitting new work and makes status `error` even + // after readiness. Upstream (wrapped sync adapter) failures after readiness + // stay health metadata through reportError. + if (this.collection) { + try { + this.collection._lifecycle.crash(error) + } catch { + // Crash must not replace the original asynchronous failure. + } + } return error } diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index bd5fd592e6..3f455370b3 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -31262,6 +31262,9 @@ function expectHydrationReceiptsPending( // A routing reproduction must not contaminate a requested generated-property // replay from this shared file. These bounded cases exclude every explicit replay. +// Terminal local persistence failures crash the Collection after readiness, +// following the IndexedDB closure precedent. Upstream (wrapped sync adapter) +// failures after readiness stay health metadata; this lane holds both. describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { const first = { id: `shared`, title: `first source value` } const second = { id: `shared`, title: `last source value` } From 08c2c14d29abcd3ef310734d5fcf3ede5ea1ba47 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 07:54:07 -0600 Subject: [PATCH 025/106] test(electric): update oracle tests for post-readiness health law After Collection readiness in a sync run, a sync failure no longer changes Collection status to 'error'. Instead: - Status stays 'ready' (no change) - Health error appears with phase='sync' and final=true - Sync adapter reports error through reportError(error, { final: true }) Before readiness, markError() still sets status to 'error' (initial error). Updated oracle tests: 1. runRejectedDurablePrefixWitness: Check health instead of status after readiness 2. runPersistenceFailureCrashOnlyWitness: Assert health state and ready status 3. assertAllSlots: Check health for terminal post-readiness errors 4. generated invalid resume transitions: Status stays ready after invalid resume 5. rejects delete then update: Status stays ready after bad messages post-readiness Test predictions: - OLD: status becomes 'error' after post-readiness failure - NEW: status stays 'ready', health.error has phase='sync' and final=true Co-authored-by: Isaac --- .../tests/electric-oracle.property.test.ts | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index fa62022c93..1e79ecad10 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -547,7 +547,14 @@ async function runRejectedDurablePrefixWitness( subscriber!([secondMessage, upToDate]) if (acceptedPrefix) { await atCheckpoint(rejectedAttempt.promise, `second durability rejection`) - await vi.waitFor(() => expect(collection.status).toBe(`error`)) + await vi.waitFor(() => { + // After Collection readiness, a sync failure doesn't change status. + // The failure is reported as a final health error instead. + expect(collection.status).toBe(`ready`) + expect(collection.health.isError).toBe(true) + expect(collection.health.error?.phase).toBe(`sync`) + expect(collection.health.error?.final).toBe(true) + }) // A later source callback may arrive after the failed durability turn. // It cannot become part of the accepted prefix or start another write. subscriber!([change(`insert`, 3, `retired`), upToDate]) @@ -573,10 +580,10 @@ async function runRejectedDurablePrefixWitness( expect(durableAttempts[1]?.keys).toContain(2) expect(durableAttempts[1]?.sourceTxids).toEqual([702]) } - // A rejected durability boundary is terminal: it cannot retry, admit the - // later source commit, or publish a resume claim for an undurable prefix. - // Clearing the accepted prefix on a later rejection is also a wrong rule. - expect(statusAtTerminalCut).toBe(`error`) + // A rejected durability boundary is terminal: the final health error stays + // until cleanup. Status stays ready; only a restart reaches ready again + // after cleanup and preload. Rows are unchanged by the sync failure. + expect(statusAtTerminalCut).toBe(`ready`) expect(durableOffset).toBe(acceptedPrefix ? `21_0` : undefined) expect(rowsFromMap(persistedRows)).toEqual(rowsFromMap(acceptedRows)) }, [() => collection.cleanup()]) @@ -2108,6 +2115,7 @@ async function runPersistenceFailureCrashOnlyWitness( const statusAtAdmissionCut = collection.status const publicErrorAtAdmissionCut = collection._lifecycle.getSyncError() + const healthAtAdmissionCut = structuredClone(collection.health) const publicationsBeforeRetiredWork = structuredClone(publications.entries) mockStream.lastOffset = `32_0` const secondMessage = change(`insert`, secondRow.id, secondRow.name) @@ -2124,13 +2132,20 @@ async function runPersistenceFailureCrashOnlyWitness( path?: unknown }) : undefined + const healthAtFailureCut = structuredClone(collection.health) expect({ statusAtAdmissionCut, + healthAtAdmissionCutIsError: healthAtAdmissionCut.isError, + healthAtAdmissionCutPhase: healthAtAdmissionCut.error?.phase, + healthAtAdmissionCutFinal: healthAtAdmissionCut.error?.final, publicErrorNameAtAdmissionCut: publicErrorAtAdmissionCut instanceof Error ? publicErrorAtAdmissionCut.name : undefined, status: collection.status, + healthIsError: healthAtFailureCut.isError, + healthPhase: healthAtFailureCut.error?.phase, + healthFinal: healthAtFailureCut.error?.final, publicErrorName: publicPersistenceError instanceof Error ? publicPersistenceError.name @@ -2148,9 +2163,15 @@ async function runPersistenceFailureCrashOnlyWitness( publicationsBeforeRetiredWork, ), }).toEqual({ - statusAtAdmissionCut: `error`, - publicErrorNameAtAdmissionCut: `PersistedCollectionDurabilityError`, - status: `error`, + statusAtAdmissionCut: `ready`, + healthAtAdmissionCutIsError: false, + healthAtAdmissionCutPhase: undefined, + healthAtAdmissionCutFinal: undefined, + publicErrorNameAtAdmissionCut: undefined, + status: `ready`, + healthIsError: true, + healthPhase: `sync`, + healthFinal: true, publicErrorName: `PersistedCollectionDurabilityError`, hasNamedPersistenceSemantics: true, preservesCause: true, @@ -2449,7 +2470,15 @@ async function runProcessGrammar( : [] expect(rowsFromCollection(runtime.collection)).toEqual(expected) if (runtime.terminalError) { - expect(runtime.collection.status).toBe(`error`) + // After Collection readiness, a sync failure does not change status. + // The error is reported as health metadata instead. + // Before readiness, markError() sets status to 'error' (initial error). + // The collection reaches ready through the initial preload, so + // post-readiness failures stay at 'ready' with health.error.phase='sync'. + expect(runtime.collection.status).toBe(`ready`) + expect(runtime.collection.health.isError).toBe(true) + expect(runtime.collection.health.error?.phase).toBe(`sync`) + expect(runtime.collection.health.error?.final).toBe(true) } if (!runtime.active && runtime.retired) { expect(runtime.collection.status).toBe(`cleaned-up`) @@ -4212,7 +4241,10 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ]) for (const batch of partition) trace.subscriber(batch) - expect(trace.collection.status).toBe(`error`) + // After the initial upToDate, the collection is ready. An invalid + // resume transition in a post-readiness batch is a health error, not + // a status change. Status stays ready. + expect(trace.collection.status).toBe(`ready`) expect(trace.collection.get(id)).toEqual( expect.objectContaining({ stable: `stable-${id}` }), ) @@ -4306,7 +4338,9 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { trace.subscriber([change(`insert`, 1, `complete`), upToDate]) for (const batch of partition) trace.subscriber(batch) - expect(trace.collection.status).toBe(`error`) + // After readiness, an invalid resume in a partition is a health error. + // Status stays ready. + expect(trace.collection.status).toBe(`ready`) expect(trace.collection.get(1)).toEqual( expect.objectContaining({ stable: `stable-1` }), ) From 0beeafae044fd3754c47a25677ffe1492de839cb Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 08:26:23 -0600 Subject: [PATCH 026/106] fix(db-sqlite-persistence-core): report terminal failures through crash instead of markError Keep reportSyncError's error latching and sync-run fencing semantics. When a terminal local persistence failure reaches reportSyncError after Collection readiness, crash instead of calling markError (which converts to health metadata at readiness). Terminal failures: marked by this.terminalFailure object, checked alongside Collection._lifecycle.hasBeenReady to determine whether to crash or report health. Co-authored-by: Isaac --- .../src/persisted.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 0de4c89cb3..2fad3597ec 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -1872,10 +1872,17 @@ class PersistedCollectionRuntime< this.syncErrorReported = true this.reportedSyncError = error - try { - markError(error) - } catch { - // Reporting must not replace the original asynchronous failure. + // Terminal local persistence failures crash the Collection, like IndexedDB + // closure does. Upstream (wrapped sync adapter) failures after readiness + // stay health metadata through reportError. + if (this.terminalFailure && this.collection?._lifecycle.hasBeenReady) { + this.collection._lifecycle.crash(error) + } else { + try { + markError(error) + } catch { + // Reporting must not replace the original asynchronous failure. + } } return error } @@ -2011,17 +2018,7 @@ class PersistedCollectionRuntime< this.remoteEnsureRetryTimer = null } this.rejectQueuedHydrationTransactions(error) - // Terminal local persistence failures crash the Collection, like IndexedDB - // closure does. This stops admitting new work and makes status `error` even - // after readiness. Upstream (wrapped sync adapter) failures after readiness - // stay health metadata through reportError. - if (this.collection) { - try { - this.collection._lifecycle.crash(error) - } catch { - // Crash must not replace the original asynchronous failure. - } - } + this.reportSyncError(error) return error } From fef9015c6d175fefa75d37a10f7603d822b61331 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 08:27:24 -0600 Subject: [PATCH 027/106] fix(db-sqlite-persistence-core): use explicit isTerminal parameter for terminal crash detection Pass isTerminal explicitly from markTerminalFailure to reportSyncError to ensure terminal state is properly detected even when called asynchronously or from multiple code paths. Default parameter value: this.terminalFailure (set at call site in markTerminalFailure). Co-authored-by: Isaac --- packages/db-sqlite-persistence-core/src/persisted.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 2fad3597ec..eb84d6b159 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -1865,7 +1865,7 @@ class PersistedCollectionRuntime< this.restartSourceAfterScopedRecovery = undefined } - reportSyncError(error: unknown): unknown { + reportSyncError(error: unknown, isTerminal = this.terminalFailure): unknown { const markError = this.syncControls.markError if (this.syncErrorReported) return this.reportedSyncError if (!markError) return error @@ -1875,7 +1875,7 @@ class PersistedCollectionRuntime< // Terminal local persistence failures crash the Collection, like IndexedDB // closure does. Upstream (wrapped sync adapter) failures after readiness // stay health metadata through reportError. - if (this.terminalFailure && this.collection?._lifecycle.hasBeenReady) { + if (isTerminal && this.collection?._lifecycle.hasBeenReady) { this.collection._lifecycle.crash(error) } else { try { From c341578fa9e3bb70a02dc2a3f86bcf760d33b5ab Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 08:27:40 -0600 Subject: [PATCH 028/106] test(query-db): flip initial error finality tests to new law per PLAN 3.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All query-db tests now passing. Deliberately flipped 3 tests: 1. 'reports an initial query failure and recovers after a successful refetch' - OLD: clearError() could heal error→ready status - NEW: status stays error until cleanup+preload restart (law 16 enforced) - Added mock value for restart pathway 2. 'does not reuse an eager initial rejection when clearError applies a valid result' - OLD: clearError refetch moved status error→ready - NEW: data accessible (utils.lastError clears) but status stays error - Prose: explains finality enforcement and data retention post-refetch 3. 'reports a failed Query cache rotation as a terminal Collection error' - OLD: post-readiness cache rotation failure→status error - NEW: post-readiness failure→health error (status stays ready, law 17) - Added health.isError assertions; data stays available Finality principle per PLAN 3.2: - Initial errors (markError before ready): final, no healing in same run - Post-ready errors (reportError after ready): health tracked, status unchanged - query-db uses final:false: observers retry via refetchInterval/reconnect/manual Tests now validate Collection lifecycle laws 16-17: - Law 16: error status is final for its attempt (no error→ready) - Law 17: post-ready failures tracked as health, not status changes Co-authored-by: Isaac --- .../tests/on-demand-sync.test.ts | 4 +++- .../tests/load-subset-lifecycle-oracle.test.ts | 1 + .../tests/ownership-lifecycle.oracle.test.ts | 13 +++++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 084af42807..38d8c7cfc8 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -3250,7 +3250,9 @@ describe(`On-Demand Sync Mode`, () => { // reported as health errors. Status stays ready; data stays available. expect(collection.status).toBe(`ready`) expect(collection.health.isError).toBe(true) - expect(collection.health.error?.message).toMatch(`trigger installation failed`) + expect(collection.health.error?.message).toMatch( + `trigger installation failed`, + ) } finally { await collection.cleanup() } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index 6d6303d77d..92ef08ab75 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -1167,6 +1167,7 @@ async function expectInitialQueryFailureStatus(): Promise { .fn() .mockRejectedValueOnce(error) .mockResolvedValueOnce([{ id: `recovered` }]) + .mockResolvedValueOnce([{ id: `recovered` }]) // for cleanup+preload restart const collection = createCollection( queryCollectionOptions({ id, diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 18b33a2e1b..d26168eecc 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -4280,7 +4280,10 @@ describe(`query collection ownership lifecycle`, () => { expectPublicRefetchObservation(expected, `recovered`, await recovery) expect(rejection).toBeUndefined() - expect(collection.status).toBe(`ready`) + // With the new design, an initial error is final for its sync attempt. + // clearError() triggers a refetch, but status stays error. The successful + // data IS accessible (see below), but status changes only with cleanup+preload. + expect(collection.status).toBe(`error`) expect(collection.utils.lastError).toBeUndefined() expect(collection.utils.errorCount).toBe(0) expect(collection.get(shared.id)?.name).toBe(recovered.name) @@ -13120,10 +13123,16 @@ it(`reports a failed Query cache rotation as a terminal Collection error`, async }) await collection._sync.loadSubset({ limit: 1 }) + expect(collection.status).toBe(`ready`) const outcome = recover() await rotationEntered.promise await expect(outcome).rejects.toBe(failure) - expect(collection.status).toBe(`error`) + // Cache rotation failure happens after readiness. With the new design, post-ready + // failures don't change status; they appear in health. Status stays ready. + expect(collection.status).toBe(`ready`) + expect(collection.health.isError).toBe(true) + expect(collection.health.error?.message).toMatch('rotation refused') + // Subsequent operations fail because of the health error await expect(collection._sync.loadSubset({ limit: 2 })).rejects.toBe(failure) }) From 83f56c7a0efbaab9ec724499547c666afceb2233 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 08:34:01 -0600 Subject: [PATCH 029/106] fix(db): publish health-only changes to live-query observers A health change below a live query notified subscribers, but the observer marked its snapshot dirty only for rows, layout, or status. Every hook then re-read the old error tree. Vue also kept the enabled query's tree after the query became disabled. Co-authored-by: Isaac --- packages/db/src/live-query-observer.ts | 2 ++ packages/vue-db/src/useLiveQuery.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index aec9e6f4af..1c07c1b66d 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -772,6 +772,8 @@ class LiveQueryObserverImpl< const healthUnsub = collection.on(`health:change`, () => { if (errorsOf() === publishedErrors) return publishedErrors = errorsOf() + // No rows or status changed, so only this marks the snapshot stale. + this.snapshotDirty = true notify(undefined, collection.status) }) const subscribeLayoutChanges = ( diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 0ca43d0d30..9cbf6de2ad 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -406,6 +406,7 @@ export function useLiveQuery( status.value = `disabled` as const persistedStatus.value = `unavailable` persistedError.value = undefined + errors.value = undefined state.clear() internalData.value = [] return From 990ac173a17608b9baeb7b6a3e9280645e508a0e Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 08:34:05 -0600 Subject: [PATCH 030/106] test(conformance): judge Collection health through every framework hook Replace the two hook health scenarios with one history-driven scenario. A small model in its prose predicts each observation: status and isError stay on the initial gate; the source node shows each health error; a final health error resists reportHealthy and later reports; rows still publish; and the error tree keeps its identity until its content changes. The initial error check moves into error-status, and the disabled law now requires an absent error tree. The React restart test asserts the health error instead of dropping its assertion. Calibration: on the stage 1 head every driver failed "a health error that is not final". Mutants killed at an assertion in React: an observer that does not mark health-only snapshots dirty, a new tree per snapshot, isError derived from health, and reportHealthy clearing a final health error. The disabled law rejected Vue's stale tree after disable. Co-authored-by: Isaac --- packages/angular-db/tests/conformance.test.ts | 7 + .../tests/conformance-disabled-laws.test.ts | 16 ++ packages/db/tests/conformance/contract.ts | 26 ++- .../db/tests/conformance/disabled-laws.ts | 2 + .../db/tests/conformance/health-source.ts | 52 +++++ packages/db/tests/conformance/suite.ts | 186 ++++++++++++++---- packages/react-db/tests/conformance.test.tsx | 7 + packages/react-db/tests/useLiveQuery.test.tsx | 19 +- packages/solid-db/tests/conformance.test.tsx | 7 + .../tests/conformance.svelte.test.ts | 8 + packages/vue-db/tests/conformance.test.ts | 7 + 11 files changed, 290 insertions(+), 47 deletions(-) create mode 100644 packages/db/tests/conformance/health-source.ts diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 054f0deca1..8e4ef12260 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -38,6 +38,7 @@ import { import { injectLiveQuery } from '../src/index' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { makeHealthSource } from '../../db/tests/conformance/health-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' import type { @@ -234,6 +235,12 @@ const angularDriver: LiveQueryDriver = { createCollection, `conformance-angular-lagging-${sourceSeq++}`, ), + makeHealthSource: (data: ReadonlyArray) => + makeHealthSource( + createCollection, + `conformance-angular-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, diff --git a/packages/db/tests/conformance-disabled-laws.test.ts b/packages/db/tests/conformance-disabled-laws.test.ts index aa7d56a922..3eef8d1bab 100644 --- a/packages/db/tests/conformance-disabled-laws.test.ts +++ b/packages/db/tests/conformance-disabled-laws.test.ts @@ -13,6 +13,7 @@ function disabled( isReady: true, isError: false, isEnabled: false, + errors: undefined, } } @@ -48,6 +49,21 @@ const faults: Array<[string, (value: ConformanceResult) => void]> = [ value.isError = true }, ], + [ + `stale error tree`, + (value) => { + value.errors = { + alias: `root`, + collectionId: `stale`, + kind: `live-query`, + status: `ready`, + isError: false, + hasErrors: false, + error: null, + sources: [], + } + }, + ], [ `stale data`, (value) => { diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index acaceccad7..67331f80f7 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -22,7 +22,7 @@ * All current scenarios must pass. A future known bug needs an independently * approved exact failure signature; a whole-test waiver could hide another bug. */ -import type { Collection } from '@tanstack/db' +import type { Collection, ErrorTreeNode } from '@tanstack/db' /** Default row shape used by the base scenarios (a "person"). */ export interface Row { @@ -78,6 +78,19 @@ export interface LaggingSourceHandle< settle: () => void } +/** + * A source Collection that reaches Collection readiness at once with its + * initial rows, then lets the scenario play its sync adapter's health reports. + */ +export interface HealthSourceHandle< + T extends { id: string } = Row, +> extends SourceHandle { + /** The sync adapter reports a health error after Collection readiness. */ + reportError: (error: unknown, options?: { final?: boolean }) => void + /** The sync adapter reports that sync works again. */ + reportHealthy: () => void +} + /** * The subset of `@tanstack/db` query operators scenarios need, supplied by the * driver from the adapter's realm. Grows as engine scenarios are ported. @@ -113,8 +126,11 @@ export interface ConformanceResult { persistedError?: unknown isError: boolean isEnabled: boolean - /** Error tree for the query and its dependencies (post-readiness health). */ - errors?: any + /** + * The hook's error tree, read without normalization so scenarios can judge + * object identity. `undefined` only for a disabled query. + */ + errors: ErrorTreeNode | undefined } /** A query-builder callback, e.g. `(q) => q.from({ items: source.collection })`. */ @@ -166,6 +182,10 @@ export interface LiveQueryDriver { ) => SourceHandle /** Create a source that starts `loading` and readies on demand (keyed by `id`). */ makeDeferredSource: () => DeferredSourceHandle + /** Create a ready source whose sync adapter health the scenario controls. */ + makeHealthSource: ( + initialData: ReadonlyArray, + ) => HealthSourceHandle /** Create an on-demand source that lags behind its settled subset (keyed by `id`). */ makeLaggingSource: () => LaggingSourceHandle /** diff --git a/packages/db/tests/conformance/disabled-laws.ts b/packages/db/tests/conformance/disabled-laws.ts index a28b23c862..0924f8b199 100644 --- a/packages/db/tests/conformance/disabled-laws.ts +++ b/packages/db/tests/conformance/disabled-laws.ts @@ -10,6 +10,8 @@ export function expectDisabledResult( expect(result.isEnabled).toBe(false) expect(result.isReady).toBe(true) expect(result.isError).toBe(false) + // A disabled query has no query definition, so it has no aliases to report. + expect(result.errors).toBeUndefined() if (representation === `absent`) { expect(result.data).toBeUndefined() expect(result.state).toBeUndefined() diff --git a/packages/db/tests/conformance/health-source.ts b/packages/db/tests/conformance/health-source.ts new file mode 100644 index 0000000000..2813b75b18 --- /dev/null +++ b/packages/db/tests/conformance/health-source.ts @@ -0,0 +1,52 @@ +import type { Collection } from '@tanstack/db' +import type { HealthSourceHandle } from './contract' + +/** + * Builds the `HealthSourceHandle` fixture from the adapter's own + * `createCollection`, so the Collection lives in the adapter's realm. Every + * driver uses this one fixture, so the five bindings face the same sync + * adapter reports. + */ +export function makeHealthSource( + createCollection: (options: any) => Collection, + id: string, + initialData: ReadonlyArray, +): HealthSourceHandle { + let controls: + | { + begin: () => void + write: (message: { type: string; value: T }) => void + commit: () => void + markReady: () => void + reportError: (error: unknown, options?: { final?: boolean }) => void + reportHealthy: () => void + } + | undefined + const collection = createCollection({ + id, + getKey: (row: T) => row.id, + startSync: true, + sync: { + sync: (params: any) => { + controls = params + params.begin() + initialData.forEach((value) => params.write({ type: `insert`, value })) + params.commit() + params.markReady() + }, + }, + }) + const writeRows = (type: string, rows: ReadonlyArray) => { + controls!.begin() + rows.forEach((value) => controls!.write({ type, value })) + controls!.commit() + } + return { + collection, + insert: (row) => writeRows(`insert`, [row]), + update: (row) => writeRows(`update`, [row]), + remove: (row) => writeRows(`delete`, [row]), + reportError: (error, options) => controls!.reportError(error, options), + reportHealthy: () => controls!.reportHealthy(), + } +} diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 36af469817..1017ec81a9 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -23,7 +23,8 @@ * Coverage: query/where/select, live insert/update/delete, orderBy, join, * groupBy/aggregate, nested aggregates, `.includes` subqueries, findOne * cardinality, disabled + transitions, deferred readiness / eager / ready-with- - * no-data, initial readiness over a lagging on-demand source, param recompilation, optimistic mutation, pre-created & config-object + * no-data, initial readiness over a lagging on-demand source, Collection + * health after readiness, param recompilation, optimistic mutation, pre-created & config-object * inputs, error status, and order-only moves. */ import { describe, expect, it } from 'vitest' @@ -85,6 +86,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { sources!.track(rawDriver.makeDeferredSource()), makeLaggingSource: () => sources!.track(rawDriver.makeLaggingSource()), + makeHealthSource: (data: ReadonlyArray) => + sources!.track(rawDriver.makeHealthSource(data)), makePrecreated: (build, options) => sources!.track(rawDriver.makePrecreated(build, options)), makeErrorSource: () => sources!.track(rawDriver.makeErrorSource()), @@ -1057,63 +1060,170 @@ export function runSuite(rawDriver: LiveQueryDriver) { const result = h.current() expect(result.status).toBe(`error`) expect(result.isError).toBe(true) - // Health law: initial gate error surfaces in errors tree - expect(result.errors).toBeDefined() + // An initial error also explains itself in the error tree: the + // mounted Collection's own node carries it with phase `initial`. expect(result.errors?.hasErrors).toBe(true) + expect(result.errors?.isError).toBe(true) + expect(result.errors?.error).toMatchObject({ + phase: `initial`, + final: true, + message: source.expectedError.message, + }) } h.unmount() }, ) + // Collection health after Collection readiness. The law (ARCHITECTURE.md + // law 16 and decisions 5, 6, and 10 of the source-failure PLAN): Collection + // status is only the initial gate, so health reports after readiness never + // change status, `isError`, or rows. They appear only in the error tree, on + // the node for the source alias. `reportHealthy()` clears a health error + // that is not final; a final health error stays until a restart. The tree + // keeps its object identity until its content changes. + // + // The model below is the whole expectation: one optional problem with a + // serial number. A report is a new problem with a new serial, so the tree + // must be a new object; a call that changes nothing keeps the serial, so + // the tree must be the same object. Row writes do not touch the model. + // Each step is observed after the driver's `flush` cut. scenario( - `health-initial-gate-error`, - `initial-gate error surfaces in errors tree hasErrors`, + `health-after-readiness`, + `health reports after readiness appear only in the error tree`, async () => { - const source = driver.makeErrorSource() - const h = driver.mountCollection(source.collection) - await h.flush() - - if (driver.errorSurface !== `throw`) { - // Initial-gate error: isError true and errors tree reflects it - expect(h.current().isError).toBe(true) - expect(h.current().errors?.hasErrors).toBe(true) - // Tree should have root node when collection itself is in error - expect(h.current().errors?.isError).toBe(true) + type Problem = { final: boolean; message: string; serial: number } + let problem: Problem | null = null + let serial = 0 + const report = (message: string, final: boolean) => { + if (problem?.final) return + problem = { final, message, serial: ++serial } + } + const healthy = () => { + if (problem && !problem.final) problem = null } - h.unmount() - }, - ) - scenario( - `health-errors-stable-identity`, - `errors tree keeps stable identity when unchanged across renders`, - async () => { - const source = driver.makeSource(SEED) + const source = driver.makeHealthSource(SEED) const h = driver.mount((q) => q .from({ items: source.collection }) - .select(({ items }: any) => items), + .select(({ items }: any) => ({ id: items.id, name: items.name })), ) await h.flush() + let rows = SEED.map(({ id, name }) => ({ id, name })) + let previous: { tree: unknown; serial: number | null } | undefined - // Ready state: errors should be defined but hasErrors false - expect(h.current().errors).toBeDefined() - expect(h.current().errors?.hasErrors).toBe(false) + const observe = (step: string) => { + const result = h.current() + const tree = result.errors + expect(result.status, step).toBe(`ready`) + expect(result.isError, `${step}: isError is the initial gate`).toBe( + false, + ) + expectUnorderedRows(result.data, rows) + expect(tree, step).toBeDefined() + const node = tree!.sources.find(({ alias }) => alias === `items`) + expect( + { + rootIsError: tree!.isError, + hasErrors: tree!.hasErrors, + node: node && { + kind: node.kind, + status: node.status, + isError: node.isError, + error: node.error && { + phase: node.error.phase, + final: node.error.final, + message: node.error.message, + }, + }, + }, + step, + ).toEqual({ + rootIsError: false, + hasErrors: problem !== null, + node: { + kind: `source`, + status: `ready`, + isError: problem !== null, + error: problem && { + phase: `sync`, + final: problem.final, + message: problem.message, + }, + }, + }) + const current = { tree, serial: problem?.serial ?? null } + if (previous) { + if (previous.serial === current.serial) + expect(tree, `${step}: unchanged tree keeps identity`).toBe( + previous.tree, + ) + else + expect(tree, `${step}: changed tree is a new object`).not.toBe( + previous.tree, + ) + } + previous = current + } - const firstErrors = h.current().errors - // Trigger a render without changing data + observe(`ready`) await h.flush() + observe(`a flush without changes`) - // Stable identity: same object when unchanged - expect(h.current().errors).toBe(firstErrors) - - // Mutate data - source.insert({ id: `999`, name: `New`, age: 50, team: `z` }) + source.insert({ id: `4`, name: `Dave`, age: 40, team: `b` }) + rows = [...rows, { id: `4`, name: `Dave` }] await h.flush() + observe(`a row write`) - // After data change, errors object might or might not be new, - // but hasErrors should still be false (no error) - expect(h.current().errors?.hasErrors).toBe(false) + const steps: Array<[string, () => void]> = [ + [ + `a health error that is not final`, + () => { + source.reportError(new Error(`stream stalled`), { final: false }) + report(`stream stalled`, false) + }, + ], + [ + `reportHealthy clears it`, + () => { + source.reportHealthy() + healthy() + }, + ], + [ + `a final health error`, + () => { + source.reportError(new Error(`stream ended`), { final: true }) + report(`stream ended`, true) + }, + ], + [ + `reportHealthy cannot clear a final health error`, + () => { + source.reportHealthy() + healthy() + }, + ], + [ + `a later report cannot replace a final health error`, + () => { + source.reportError(new Error(`late`), { final: false }) + report(`late`, false) + }, + ], + ] + for (const [step, act] of steps) { + act() + await h.flush() + observe(step) + } + + source.update({ id: `1`, name: `Johnny Doe`, age: 30, team: `a` }) + rows = rows.map((row) => + row.id === `1` ? { id: `1`, name: `Johnny Doe` } : row, + ) + await h.flush() + observe(`rows still publish under a final health error`) h.unmount() }, ) @@ -1155,7 +1265,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(34) + expect(registry.size).toBe(33) }) }) } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index c05f031ed4..c96296758e 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -27,6 +27,7 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { makeHealthSource } from '../../db/tests/conformance/health-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import type { RenderHookResult } from '@testing-library/react' import type { @@ -213,6 +214,12 @@ const reactDriver: LiveQueryDriver = { createCollection, `conformance-react-lagging-${sourceSeq++}`, ), + makeHealthSource: (data: ReadonlyArray) => + makeHealthSource( + createCollection, + `conformance-react-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 13c5776c12..7db3f4d800 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -994,7 +994,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).toBe(liveQueryCollection) }) - it(`shows recovery of a pre-created live query after dependent-first cleanup`, async () => { + it(`restarts a pre-created live query after a final health error and dependent-first cleanup`, async () => { let starts = 0 let failSource!: () => void const source = createCollection({ @@ -1009,7 +1009,10 @@ describe(`Query Collections`, () => { }) methods.commit() methods.markReady() - failSource = () => methods.markError(new Error(`source sync failed`)) + failSource = () => + methods.reportError(new Error(`source sync failed`), { + final: true, + }) }, }, }) @@ -1023,11 +1026,13 @@ describe(`Query Collections`, () => { await waitFor(() => expect(result.current.status).toBe(`ready`)) expect(result.current.data[0]?.text).toBe(`Todo 1`) + // After Collection readiness, a final health error leaves the initial + // gate alone: status and rows stay, and only the error tree shows it. act(() => failSource()) - // After initial readiness, markError becomes a health error, not a status change. - // The status remains ready and data is stale but still visible. - // This is the new health law: status only reflects the initial gate. - // We don't expect status to change to error. + await waitFor(() => expect(result.current.errors?.hasErrors).toBe(true)) + expect(result.current.status).toBe(`ready`) + expect(result.current.isError).toBe(false) + expect(result.current.data[0]?.text).toBe(`Todo 1`) await act(async () => { await todosQuery.cleanup() @@ -1038,6 +1043,8 @@ describe(`Query Collections`, () => { await waitFor(() => expect(result.current.status).toBe(`ready`)) expect(result.current.collection).toBe(todosQuery) expect(result.current.data[0]?.text).toBe(`Todo 2`) + // Only a restart clears a final health error. + expect(result.current.errors?.hasErrors).toBe(false) } finally { unmount() reports.mockRestore() diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 3462f75dd0..9b5d62cc49 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -28,6 +28,7 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { makeHealthSource } from '../../db/tests/conformance/health-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' import type { @@ -230,6 +231,12 @@ const solidDriver: LiveQueryDriver = { createCollection, `conformance-solid-lagging-${sourceSeq++}`, ), + makeHealthSource: (data: ReadonlyArray) => + makeHealthSource( + createCollection, + `conformance-solid-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index ef104d7d83..cb5b4abf0c 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -27,6 +27,7 @@ import { import { useLiveQuery } from '../src/useLiveQuery.svelte.js' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { makeHealthSource } from '../../db/tests/conformance/health-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import type { ConformanceResult, @@ -142,6 +143,7 @@ function makeHandle(getQuery: () => any, dispose: () => void): LiveQueryHandle { isPersistedReady: query?.isPersistedReady, persistedError: query?.persistedError, isError: query?.isError, + errors: query?.errors, // svelte-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: query?.status !== `disabled`, }) @@ -220,6 +222,12 @@ const svelteDriver: LiveQueryDriver = { createCollection, `conformance-svelte-lagging-${sourceSeq++}`, ), + makeHealthSource: (data: ReadonlyArray) => + makeHealthSource( + createCollection, + `conformance-svelte-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 3e0af8060a..a003703b45 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -27,6 +27,7 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { makeHealthSource } from '../../db/tests/conformance/health-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' import type { @@ -228,6 +229,12 @@ const vueDriver: LiveQueryDriver = { createCollection, `conformance-vue-lagging-${sourceSeq++}`, ), + makeHealthSource: (data: ReadonlyArray) => + makeHealthSource( + createCollection, + `conformance-vue-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, From 5813681cf2c0334f2b1497276a79adb91994bed7 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 09:35:56 -0600 Subject: [PATCH 031/106] docs(glossary): add crash to the Collection health terms IndexedDB closure and terminal local persistence failures move a Collection to error even after Collection readiness. Name that transition beside the initial error and the final health error. Co-authored-by: Isaac --- docs/contributing/glossary.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 9ce196dffc..a2b2752148 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -68,6 +68,7 @@ sync after that. Neither one changes the other. | --- | --- | --- | | initial gate | The role of Collection status during a sync run's initial sync. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `markError()`. After Collection readiness in a sync run, the status no longer reflects sync failures. | Collection health or a live query's request errors. | | initial error | The `markError()` failure that ends a sync run's initial gate in `error`, recorded with `phase: 'initial'`. It is final for its sync run: `markReady()` afterward throws, and only a restart can reach `ready`. | Health error or a retry. | +| crash | Moving a Collection to `error` from any active status, including after Collection readiness, because the Collection can no longer serve reads or admit writes in its sync run. Core uses it for IndexedDB closure, terminal local persistence failures, and internal invariant failures. It is final for the sync run; only a restart recovers. | Initial error, final health error, or cleanup. | | Collection health | The sync adapter's report on a Collection's sync after Collection readiness in the current sync run, read through `collection.health` and `health:change`. Only `reportError()` and `reportHealthy()` change it. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | | health error | One problem reported through `reportError()`, recorded as `CollectionErrorInfo` with `phase: 'sync'`. A health error that is not final means the sync adapter is still trying in the current sync run; `reportHealthy()` clears it. | Initial error or request error. | | final health error | A health error with `final: true`: the sync adapter stopped trying in the current sync run. Only a restart clears it; `reportHealthy()` does not. | A Collection status of `error`. | @@ -229,6 +230,7 @@ Use nouns for state and verbs for transitions: - A sync adapter **gives up** with `markError()` before Collection readiness. After it, the adapter **reports** a health error and **reports healthy**. A restart **clears** a final health error. +- A Collection **crashes** when it can no longer serve reads or admit writes. Use `settled` for a promise that fulfilled or rejected. Use `complete` for named coverage, content, history, or a non-promise operation only when no stronger From 51e98e9cddb00a7cbfc3138214d81899313dd44e Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 09:39:06 -0600 Subject: [PATCH 032/106] docs(glossary): include request errors in a live query's Collection health A live-query Collection has no sync adapter of its own, so its health also carries its request errors. Keep the public `request` name: applications understand a failed request for more data better than subset loads. Co-authored-by: Isaac --- docs/contributing/glossary.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index a2b2752148..0538171337 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -69,10 +69,10 @@ sync after that. Neither one changes the other. | initial gate | The role of Collection status during a sync run's initial sync. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `markError()`. After Collection readiness in a sync run, the status no longer reflects sync failures. | Collection health or a live query's request errors. | | initial error | The `markError()` failure that ends a sync run's initial gate in `error`, recorded with `phase: 'initial'`. It is final for its sync run: `markReady()` afterward throws, and only a restart can reach `ready`. | Health error or a retry. | | crash | Moving a Collection to `error` from any active status, including after Collection readiness, because the Collection can no longer serve reads or admit writes in its sync run. Core uses it for IndexedDB closure, terminal local persistence failures, and internal invariant failures. It is final for the sync run; only a restart recovers. | Initial error, final health error, or cleanup. | -| Collection health | The sync adapter's report on a Collection's sync after Collection readiness in the current sync run, read through `collection.health` and `health:change`. Only `reportError()` and `reportHealthy()` change it. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | +| Collection health | A Collection's report of problems after Collection readiness in the current sync run, read through `collection.health` and `health:change`. A sync adapter changes it only through `reportError()` and `reportHealthy()`. A live-query Collection's health also carries its own request errors. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | | health error | One problem reported through `reportError()`, recorded as `CollectionErrorInfo` with `phase: 'sync'`. A health error that is not final means the sync adapter is still trying in the current sync run; `reportHealthy()` clears it. | Initial error or request error. | | final health error | A health error with `final: true`: the sync adapter stopped trying in the current sync run. Only a restart clears it; `reportHealthy()` does not. | A Collection status of `error`. | -| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, recorded with `phase: 'request'` on that query's error tree node. The source Collection's health does not change. It clears when a later subset load for that demand fulfills or the demand retires. | Health error or request data. | +| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, recorded with `phase: 'request'` on that query's error tree node and, for a live query, in its live-query Collection's health. The source Collection's health does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Health error or request data. | | error tree | The `errors` value of a live query or Effect: one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged. | Collection health or an error list. | | error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. An include node summarizes all its per-row child Collections in `instances`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | From c3b096c65a96934579c3836a4b02898586a45e4a Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 09:47:39 -0600 Subject: [PATCH 033/106] fix(db-sqlite-persistence-core): crash on persistence failures by origin Every failure reached reportSyncError, so a local I/O or coordinator failure after Collection readiness became a final health error through core's markError forwarding, and the Collection kept admitting work it could not persist. Route by origin instead: the wrapped sync adapter's own markError keeps core's meaning, while a failure that persistence raises crashes the Collection, as IndexedDB closure does. This replaces the isTerminal check and keeps the first-error latch and sync-run fence. The terminal-state probes now observe crash, where persistence reports. Co-authored-by: Isaac --- .../src/persisted.ts | 31 ++++--- .../tests/persisted-oracle.test.ts | 86 +++++++++---------- 2 files changed, 57 insertions(+), 60 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index eb84d6b159..1ade14d145 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -1865,24 +1865,27 @@ class PersistedCollectionRuntime< this.restartSourceAfterScopedRecovery = undefined } - reportSyncError(error: unknown, isTerminal = this.terminalFailure): unknown { + /** + * Reports one failure for the current sync run. The wrapped sync adapter's + * own markError keeps core's meaning: final before Collection readiness, a + * final health error after it. A failure raised by persistence itself means + * the Collection can no longer serve reads or admit writes, so it crashes. + */ + reportSyncError( + error: unknown, + origin: `persistence` | `sync adapter` = `persistence`, + ): unknown { const markError = this.syncControls.markError if (this.syncErrorReported) return this.reportedSyncError if (!markError) return error this.syncErrorReported = true this.reportedSyncError = error - // Terminal local persistence failures crash the Collection, like IndexedDB - // closure does. Upstream (wrapped sync adapter) failures after readiness - // stay health metadata through reportError. - if (isTerminal && this.collection?._lifecycle.hasBeenReady) { - this.collection._lifecycle.crash(error) - } else { - try { - markError(error) - } catch { - // Reporting must not replace the original asynchronous failure. - } + try { + if (origin === `sync adapter`) markError(error) + else this.collection?._lifecycle.crash(error) + } catch { + // Reporting must not replace the original asynchronous failure. } return error } @@ -5744,7 +5747,7 @@ function createWrappedSyncConfig< const getTerminalFailure = () => runtime.getCurrentTerminalFailure() const reportUpstreamError = (error: unknown) => { if (persistedReadiness?.getSnapshot().status !== `loading`) { - runtime.reportSyncError(error) + runtime.reportSyncError(error, `sync adapter`) return } // An early network error must not stop the live-query graph before @@ -5752,7 +5755,7 @@ function createWrappedSyncConfig< void (fullStartPromise ?? runtime.ensureStarted()).then( () => { if (!isCleanedUp() && !getTerminalFailure()) { - runtime.reportSyncError(error) + runtime.reportSyncError(error, `sync adapter`) } }, () => undefined, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 3f455370b3..7002185e57 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -6170,7 +6170,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ) const failing: LoadSubsetOptions = { limit: 1 } const sibling: LoadSubsetOptions = { limit: 2 } - const markError = vi.spyOn(collection._lifecycle, `markError`) + const crash = vi.spyOn(collection._lifecycle, `crash`) const unhandled: Array = [] const captureUnhandled = (error: unknown) => unhandled.push(error) process.on(`unhandledRejection`, captureUnhandled) @@ -6189,7 +6189,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { error: collection._lifecycle.getSyncError(), unloadThenCalls, unloadOptions: sourceUnloadMock.mock.calls.map(([options]) => options), - lifecycleErrors: markError.mock.calls.map(([error]) => error), + lifecycleErrors: crash.mock.calls.map(([error]) => error), unhandled, }).toEqual({ status: `error`, @@ -6202,7 +6202,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { await flushAsyncWork() expect(sourceUnloadMock).toHaveBeenCalledTimes(2) - expect(markError).toHaveBeenCalledTimes(1) + expect(crash).toHaveBeenCalledTimes(1) } finally { process.off(`unhandledRejection`, captureUnhandled) await collection.cleanup() @@ -7201,29 +7201,27 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { try { await collection.stateWhenReady() - const originalMarkError = collection._lifecycle.markError.bind( + const originalCrash = collection._lifecycle.crash.bind( collection._lifecycle, ) - vi.spyOn(collection._lifecycle, `markError`).mockImplementation( - (error) => { - sourceParams.begin() - sourceParams.write({ - type: `insert`, - value: { - get id() { - reentrantKeyReads++ - return `reentrant` - }, - title: `must be fenced before error observation`, + vi.spyOn(collection._lifecycle, `crash`).mockImplementation((error) => { + sourceParams.begin() + sourceParams.write({ + type: `insert`, + value: { + get id() { + reentrantKeyReads++ + return `reentrant` }, - }) - reentrantReceipt = Promise.resolve(sourceParams.commit()).then( - () => ({ status: `fulfilled` as const }), - (reason: unknown) => ({ status: `rejected` as const, reason }), - ) - originalMarkError(error) - }, - ) + title: `must be fenced before error observation`, + }, + }) + reentrantReceipt = Promise.resolve(sourceParams.commit()).then( + () => ({ status: `fulfilled` as const }), + (reason: unknown) => ({ status: `rejected` as const, reason }), + ) + originalCrash(error) + }) sourceParams.begin() sourceParams.write({ @@ -22226,11 +22224,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { persistence: { adapter, coordinator }, }), ) - const markError = collection._lifecycle.markError.bind( - collection._lifecycle, - ) + const crash = collection._lifecycle.crash.bind(collection._lifecycle) const report = vi - .spyOn(collection._lifecycle, `markError`) + .spyOn(collection._lifecycle, `crash`) .mockImplementation((error) => { reported.push(error) // Invoke while the error is being reported, before core marks the @@ -22238,7 +22234,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { reentrantFailure = Promise.resolve( collection._sync.loadSubset(sibling), ).catch((reason: unknown) => reason) - markError(error) + crash(error) errorReported.resolve() }) let hasPrimaryFailure = false @@ -22822,26 +22818,24 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { try { await collection.stateWhenReady() - const originalMarkError = collection._lifecycle.markError.bind( + const originalCrash = collection._lifecycle.crash.bind( collection._lifecycle, ) - vi.spyOn(collection._lifecycle, `markError`).mockImplementation( - (error) => { - reentrantOutcome = Promise.resolve( - collection.utils.acceptMutations({ - mutations: [ - reentrantMutation as unknown as PendingMutation< - Record - >, - ], - }), - ).then( - () => ({ status: `fulfilled` as const }), - (reason: unknown) => ({ status: `rejected` as const, reason }), - ) - originalMarkError(error) - }, - ) + vi.spyOn(collection._lifecycle, `crash`).mockImplementation((error) => { + reentrantOutcome = Promise.resolve( + collection.utils.acceptMutations({ + mutations: [ + reentrantMutation as unknown as PendingMutation< + Record + >, + ], + }), + ).then( + () => ({ status: `fulfilled` as const }), + (reason: unknown) => ({ status: `rejected` as const, reason }), + ) + originalCrash(error) + }) const primary = collection.insert({ id: `primary-local`, From de5faabefe597b4c296a2508d169709509306cfb Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 12:58:54 -0700 Subject: [PATCH 034/106] fix(db): end live queries and Effects when a source Collection crashes A source Collection in error now ends every live query and Effect above it, at any time. After initial-query readiness a source reaches error only by a crash or by an on-demand source's initial error, and a consumer cannot keep serving rows from a Collection that cannot serve reads. Health errors never change status, so they still leave consumers running. This removes the readiness checks in handleSourceStatusChange and the Effect status handler. Oracle: the history grammar gains `crash`, the model ends a query whose input reaches error, and the Effect lane crosses a crash and markError after initial-query readiness. On the previous head every shape failed the crash history at the gate assertion, and six Effect cells failed. The IndexedDB closure witness returns to main's expectation, and law 16 states the rule. Co-authored-by: Isaac --- packages/db/src/query/effect.ts | 8 +- packages/db/src/query/live/ARCHITECTURE.md | 37 ++- .../query/live/collection-config-builder.ts | 27 +- .../tests/indexed-db/transport-oracle.test.ts | 8 +- .../db/tests/source-health-oracle.test.ts | 274 ++++++++++-------- 5 files changed, 195 insertions(+), 159 deletions(-) diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index f3e660213a..7458d401ab 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -742,12 +742,8 @@ class EffectPipelineRunner { const { status } = event - // A Collection failure before the effect's initial gate ends the effect. - // After it, the failure is health metadata and the effect keeps running. - if (status === `error` && this.reachedReady) { - this.reportHealth() - return - } + // A source in error ends the effect, before or after its gate: an + // initial error or a crash. Health errors never change the status. if (status === `error`) { this.onSourceError( new Error( diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index d8ba6a0121..45d801d7a3 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1580,22 +1580,27 @@ create recursive Collection machinery. counts as a preload. A deferring live query keeps its sources from garbage collection but is not counted in their `subscriberCount` or `subscribers:change`, which adapters read as a request for live data. -16. **Final initial errors and Collection health:** a sync run's initial - error is final for that sync run; a Collection moves from `error` to - `ready` only through a restart. Before a live query's initial-query readiness, a - Collection that fails fails the query's sync run, for every shape. After its - initial-query readiness, a Collection's later problem is health metadata: the query keeps - its status and rows, and its error tree reports the problem. A lazy load - or a root Collection's load that fails after initial-query readiness is a request - error on the query's node. A lazy demand's error clears when that demand - settles or is released; a root Collection's error clears when a later load of - that Collection settles. Query failures and contract violations, such as a - graph failure or a Collection cleaned up under the query, still end the - query. So does an IndexedDB connection closure: it ends read and - admission authority, which no health report can restore. An Effect has - the same two axes, with its gate at its settled initial loads: a load - that fails after the gate is a request error on the Effect's tree, - delivered through `onHealthChange`, and the Effect keeps running. +16. **Final initial errors, crashes, and Collection health:** a sync run's + initial error is final for that sync run; a Collection moves from `error` + to `ready` only through a restart. A source Collection in `error` ends the + sync run of every live query above it, for every shape and at any time. + Before the query's initial-query readiness this is an initial error. + After it, a source reaches `error` only by an initial error during an + on-demand source's own initial sync, or by a crash: IndexedDB closure, a + terminal local persistence failure, or an internal invariant failure. A + query cannot keep serving rows from a Collection that cannot serve reads. + Every other problem after initial-query readiness is health metadata: the + query keeps its status and rows, and its error tree reports the problem. + A lazy load or a root Collection's load that fails after initial-query + readiness is a request error on the query's node. A lazy demand's error + clears when that demand settles or is released; a root Collection's error + clears when a later load of that Collection settles. Query failures and + contract violations, such as a graph failure or a Collection cleaned up + under the query, also end the query. An Effect follows the same rules, + with its initial-query readiness at its settled initial loads: a source in + `error` ends it through `onSourceError`, and a load that fails after its + initial-query readiness is a request error on the Effect's error tree, + delivered through `onHealthChange`, while the Effect keeps running. 17. **Error tree:** a live query's `errors` has one node for itself and one per source alias in query order, recursing through nested live queries. An include's source has `kind: 'include'`; when the include materializes a diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 422d1e7461..f7df4fb7ca 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1170,17 +1170,16 @@ export class CollectionConfigBuilder< ) { const { status } = event - // A source's initial error is final for its attempt. Before this query's - // first ready it is this query's gate error, for every shape. After it, - // the source node shows the error and this query keeps its rows. + // A source in error ends this query's sync run, for every shape. Before + // initial-query readiness it is an initial error. After it, a source can + // reach error only by crashing, and a query cannot keep serving rows from + // a Collection that cannot serve reads. if (status === `error`) { - if (this.liveQueryCollection?.status !== `ready`) { - this.transitionToError( - devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' entered error state` - : codedMessage(172, { collectionId }), - ) - } + this.transitionToError( + devBuild() && process.env.NODE_ENV !== `production` + ? `Source collection '${collectionId}' entered error state` + : codedMessage(172, { collectionId }), + ) return } @@ -1264,10 +1263,10 @@ export class CollectionConfigBuilder< : message, ) - // These are query failures or contract violations, such as a source - // cleaned up under this query, so they end the query even after ready. - // A source's own failure after this query's first ready, and a failed - // request, are health metadata and never reach here. + // These are query failures, crashed sources, or contract violations such + // as a source cleaned up under this query, so they end the query even + // after initial-query readiness. A source's health error and a request + // error are health metadata and never reach here. this.liveQueryCollection?._lifecycle.crash(error ?? new Error(message)) } diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index 7b532f27f7..9bd0cb849e 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -878,10 +878,10 @@ it('reports connection closure immediately and supports fresh Collections', asyn stores: ['items'], }) h.descriptors.push(upgraded) - // The source retires. A live query that was already ready keeps its rows - // and reports the source's error in its error tree. + // Closure crashes the source, and a crashed source ends every live query + // above it, even one that was already ready. Both trees explain why. expect(old.status).toBe('error') - expect(live.status).toBe('ready') + expect(live.status).toBe('error') expect(live.errors.sources[0]).toMatchObject({ status: 'error', isError: true, @@ -890,7 +890,7 @@ it('reports connection closure immediately and supports fresh Collections', asyn await fresh.insert({ id: 1, name: 'new' }).isPersisted.promise await Channel.deliver() expect(old.status).toBe('error') - expect(live.status).toBe('ready') + expect(live.status).toBe('error') expect(live.errors.hasErrors).toBe(true) expect(fresh.status).toBe('ready') assertRows( diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index e635fb4e3e..ce9063cccf 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -3,7 +3,7 @@ * * A Collection answers two questions on two separate axes: * - * - **The gate (`status`)** says whether the data can be shown yet. It is + * - **The initial gate (`status`)** says whether the data can be shown yet. It is * decided only by the initial sync of a sync run. While the sync adapter retries * it stays `loading`. When the sync adapter gives up before Collection readiness * (`markError`), the sync run fails for good: the status is `error`, and a @@ -33,19 +33,23 @@ * Collection over that source would have, so its tree also reaches through * a nested live query. * - * An IndexedDB connection closure is not health: it ends read and admission - * authority, so the Collection moves to `error` even after ready. The - * IndexedDB oracles own that case. + * A crash is not health either. When a Collection can no longer serve reads + * or admit writes, as after IndexedDB closure or a terminal local persistence + * failure, it moves to `error` even after Collection readiness. The IndexedDB + * and persistence oracles own those causes; this oracle drives the transition + * directly. * * A live query's own gate follows its sources: it becomes ready when every * Collection is ready, and a Collection that fails before the query's initial-query readiness * fails the query's sync run, whatever the query's shape. After the query's - * initial-query readiness, its sources can only report health, and the query keeps its - * status and rows. + * initial-query readiness, its sources report problems only as health, and + * the query keeps its status and rows. The one exception is a crash: a query + * cannot keep serving rows from a Collection that cannot serve reads, so a + * crashed Collection ends every live query above it, as cleanup does. * * ## The model * - * The model keeps, for each Collection, its gate and its health problem (none, + * The model keeps, for each Collection, its initial gate and its health problem (none, * non-final, or final), with a serial for each reported problem. It derives * each live query's gate from its Collections, using only the rules above, and * the include's parent rows from whether `B` synced its row while the @@ -56,7 +60,8 @@ * * Steps act on Collection `A` or `B`: `ready` writes the Collection's row and calls * `markReady()`; `fail` calls `markError()`; `report` and `reportFinal` call - * `reportError()`; `healthy` calls `reportHealthy()`. `restart` cleans up the + * `reportError()`; `healthy` calls `reportHealthy()`; `crash` moves the + * Collection to `error` through its lifecycle, as a crash cause would. `restart` cleans up the * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw * for `markReady()` after `markError()`, and for a health report before @@ -78,7 +83,7 @@ * the current tree. Request lanes check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's * node, and that it clears. Effect lanes check the Effect's gate and its - * request errors after the gate. + * request errors after its initial-query readiness. * * ## Limits * @@ -120,7 +125,7 @@ type Shape = `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` type Step = | { - kind: `ready` | `fail` | `report` | `reportFinal` | `healthy` + kind: `ready` | `fail` | `report` | `reportFinal` | `healthy` | `crash` source: SourceName } | { kind: `restart` } @@ -139,7 +144,7 @@ type Model = { query: Gate inner: Gate serial: number - // Parent rows the include query's attempt has received from `B`. + // Parent rows the include query's sync run has received from `B`. parentRows: number } @@ -163,11 +168,14 @@ function freshModel(): Model { } } -// A live query's gate follows its sources and never leaves error or ready -// within an attempt. +// A live query's gate follows its sources and never leaves error within a +// sync run. An input in `error` ends the query's sync run whenever it appears. +// After the query is ready, an input reaches `error` only by crashing, since +// `fail` after Collection readiness is health. function deriveGate(current: Gate, inputs: ReadonlyArray): Gate { - if (current !== `loading`) return current + if (current === `error`) return `error` if (inputs.some((gate) => gate === `error`)) return `error` + if (current === `ready`) return `ready` if (inputs.every((gate) => gate === `ready`)) return `ready` return `loading` } @@ -183,8 +191,8 @@ function deriveQueries(shape: Shape, model: Model): void { model.inner = deriveGate(model.inner, gates([`A`])) model.query = model.inner } else { - // A running attempt receives `B`'s row once `B` syncs it; a failed - // attempt receives nothing more. + // A running sync run receives `B`'s row once `B` syncs it; a failed + // sync run receives nothing more. if ( shape === `include` && model.query !== `error` && @@ -230,6 +238,10 @@ function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { if (source.gate !== `ready`) outcome = `throws` else if (source.problem && !source.problem.final) source.problem = null break + case `crash`: + // From any active status; a Collection already in error stays there. + source.gate = `error` + break } deriveQueries(shape, model) return outcome @@ -318,8 +330,8 @@ function expectedTree( ]) case `include`: // The include node rolls up the per-row child Collections: one per - // parent row the attempt received. They stay until a restart, even - // when the attempt fails later. No grammar step fails a child + // parent row the sync run received. They stay until a restart, even + // when the sync run fails later. No grammar step fails a child // Collection, so none is in error. return queryNode(`root`, model.query, [ source(`b`, B), @@ -523,7 +535,7 @@ async function runHistory( let events: Array = [] let announced: string = query.status let stop = () => {} - // Cleanup without subscribers drops event listeners, so each attempt + // Cleanup without subscribers drops event listeners, so each sync run // registers its own. const begin = () => { stop() @@ -587,6 +599,10 @@ async function runHistory( params.markError(new Error(`${step.source} gave up`)) } else if (step.kind === `healthy`) { params.reportHealthy() + } else if (step.kind === `crash`) { + params.collection._lifecycle.crash( + new Error(`${step.source} crashed`), + ) } else { params.reportError(new Error(`${step.source} problem`), { final: step.kind === `reportFinal`, @@ -718,6 +734,16 @@ const namedHistories: Record> = { on(`ready`, `A`), on(`ready`, `B`), ], + 'a crash after initial-query readiness ends the query': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + on(`crash`, `A`), + on(`healthy`, `A`), + restart, + on(`ready`, `A`), + on(`ready`, `B`), + ], 'a health report before Collection readiness throws': [ on(`report`, `A`), on(`healthy`, `B`), @@ -744,6 +770,7 @@ describe(`source failure and health oracle`, () => { `report` as const, `reportFinal` as const, `healthy` as const, + `crash` as const, ), fc.constantFrom(`A` as const, `B` as const), ) @@ -765,11 +792,11 @@ describe(`source failure and health oracle`, () => { /** * A live query's lazy source loads only the keys its rows reach, and an * on-demand root source loads the window the query shows. After the query's - * first ready, a load that fails is a request error on the query's own node: + * initial-query readiness, a load that fails is a request error on the query's own node: * the source stays healthy and the query keeps its status. A lazy demand's * error clears when the same demand later settles, or when the query stops * needing it; a root source's error clears when a later load of that source - * settles. Before the first ready, the same failure fails the query's attempt. + * settles. Before initial-query readiness, the same failure fails the query's sync run. */ describe(`request errors`, () => { function createRequestHistory() { @@ -840,7 +867,7 @@ describe(`request errors`, () => { } } - it(`a failed load after ready is a request error that clears when its demand settles`, () => + it(`a failed load after initial-query readiness is a request error that clears when its demand settles`, () => withHistory( async ({ query, children, outcomes, writeParent, markParentsReady }) => { const preload = query.preload() @@ -886,7 +913,7 @@ describe(`request errors`, () => { expect(query.status).toBe(`ready`) })) - it(`a failed load before ready fails the query's attempt`, () => + it(`a failed load before initial-query readiness fails the query's sync run`, () => withHistory(async ({ query, outcomes, writeParent, markParentsReady }) => { outcomes.push(`fail`) const preload = query.preload() @@ -904,7 +931,7 @@ describe(`request errors`, () => { }) describe(`root request errors`, () => { - it(`a failed root load after ready is a request error that clears when a later load settles`, async () => { + it(`a failed root load after initial-query readiness is a request error that clears when a later load settles`, async () => { const outcomes: Array<`ok` | `fail`> = [] const source = createCollection({ id: `health-root-${Math.random().toString(36).slice(2)}`, @@ -964,108 +991,120 @@ describe(`root request errors`, () => { // --------------------------------------------------------------------------- /** - * An Effect works the same as a query. Its gate is its initial load: every + * An Effect works the same as a query. Its initial-query readiness is its + * initial load: every * initial load has settled and every eager source is ready, whether or not it * has `skipInitial`. `skipInitial` only decides whether the initial rows are - * delivered. A source failure before the gate disposes the Effect through - * `onSourceError`. After the gate, a source problem does not dispose it: - * `onHealthChange` receives the error tree, with one node per source alias. - * An on-demand source can fail its own initial sync after the gate, because - * the gate follows the Effect's settled loads; that failure is health too. + * delivered. As for a live query, a source in `error` ends the Effect + * through `onSourceError`, before or after its initial-query readiness. A source reaches `error` + * by an initial error or by a crash. An on-demand source can have its initial + * error after initial-query readiness, because that readiness follows the Effect's settled loads. + * Every other source problem after it is a health error: it does not + * dispose the Effect, and `onHealthChange` receives the error tree, with one + * node per source alias. * - * The lane crosses `skipInitial`, the source mode, and a failure before or - * after the gate. Before the gate, an eager source has not called - * `markReady()`, and an on-demand source still has its first load pending. + * The lane crosses `skipInitial`, the source mode, and when the source fails. + * Before initial-query readiness, an eager source has not called `markReady()`, and an + * on-demand source still has its first load pending. After it, the + * source calls `markError()`, which is an initial error for an on-demand + * source still in its initial sync and a final health error for an eager + * source after Collection readiness, or it crashes. */ describe(`effect health`, () => { type EffectHistory = { skipInitial: boolean syncMode: `eager` | `on-demand` - failBeforeGate: boolean + failure: `before gate` | `markError after gate` | `crash after gate` } const histories: Array = [true, false].flatMap((skipInitial) => ([`eager`, `on-demand`] as const).flatMap((syncMode) => - [true, false].map((failBeforeGate) => ({ - skipInitial, - syncMode, - failBeforeGate, - })), + ( + [`before gate`, `markError after gate`, `crash after gate`] as const + ).map((failure) => ({ skipInitial, syncMode, failure })), ), ) - it.each(histories)( - `%j`, - async ({ skipInitial, syncMode, failBeforeGate }) => { - let params!: SyncParams - let settleLoad = () => {} - const writeRow = () => { - params.begin() - params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) - params.commit() - } - const source = createCollection({ - id: `health-effect-${syncMode}-${Math.random().toString(36).slice(2)}`, - getKey: (row) => row.id, - syncMode, - gcTime: 0, - sync: { - sync: (next) => { - params = next - if (syncMode === `eager`) return undefined - return { - loadSubset: () => - new Promise((resolve) => { - settleLoad = () => { - writeRow() - resolve() - } - }), - } - }, - }, - }) - const sourceErrors: Array = [] - const trees: Array = [] - const delivered: Array = [] - const effect = createEffect({ - query: (q) => q.from({ row: source }), - skipInitial, - onBatch: (events) => { - delivered.push(...events.map((event) => event.key)) + it.each(histories)(`%j`, async ({ skipInitial, syncMode, failure }) => { + let params!: SyncParams + let settleLoad = () => {} + const writeRow = () => { + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + } + const source = createCollection({ + id: `health-effect-${syncMode}-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode, + gcTime: 0, + sync: { + sync: (next) => { + params = next + if (syncMode === `eager`) return undefined + return { + loadSubset: () => + new Promise((resolve) => { + settleLoad = () => { + writeRow() + resolve() + } + }), + } }, - onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), - }) - const consoleError = console.error - console.error = () => {} - try { - await flushPromises() - if (failBeforeGate) { - params.markError(new Error(`gave up`)) - await flushPromises() - // Model: the Effect never passed its gate, so the failure ends it. - expect(effect.disposed).toBe(true) - expect(sourceErrors).toHaveLength(1) - expect(trees).toEqual([]) - return - } - // Pass the gate: the eager source becomes ready, or the on-demand - // source's first load settles. - if (syncMode === `eager`) { - writeRow() - params.markReady() - } else { - settleLoad() - } + }, + }) + const sourceErrors: Array = [] + const trees: Array = [] + const delivered: Array = [] + const effect = createEffect({ + query: (q) => q.from({ row: source }), + skipInitial, + onBatch: (events) => { + delivered.push(...events.map((event) => event.key)) + }, + onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (errors) => trees.push(errors), + }) + const consoleError = console.error + console.error = () => {} + try { + await flushPromises() + if (failure === `before gate`) { + params.markError(new Error(`gave up`)) await flushPromises() - // Model: only `skipInitial` withholds the initial rows. - expect(delivered).toEqual(skipInitial ? [] : [1]) + // Model: the Effect never reached initial-query readiness, so the failure + // ends it. + expect(effect.disposed).toBe(true) + expect(sourceErrors).toHaveLength(1) + expect(trees).toEqual([]) + return + } + // Reach initial-query readiness: the eager source becomes ready, or the on-demand + // source's first load settles. + if (syncMode === `eager`) { + writeRow() + params.markReady() + } else { + settleLoad() + } + await flushPromises() + // Model: only `skipInitial` withholds the initial rows. + expect(delivered).toEqual(skipInitial ? [] : [1]) + if (failure === `crash after gate`) { + params.collection._lifecycle.crash(new Error(`crashed`)) + } else { params.markError(new Error(`gave up`)) - await flushPromises() - // Model: after the gate, the failure is health. - expect(effect.disposed).toBe(false) - expect(sourceErrors).toEqual([]) + } + await flushPromises() + // Model: the source reaches `error` by a crash, or by an initial + // error while an on-demand source is still in its initial sync. Either + // ends the Effect. An eager source's markError after Collection + // readiness is a final health error instead. + const ends = failure === `crash after gate` || syncMode === `on-demand` + expect(effect.disposed).toBe(ends) + expect(sourceErrors).toHaveLength(ends ? 1 : 0) + if (!ends) { expect(trees.at(-1)).toMatchObject({ kind: `live-query`, hasErrors: true, @@ -1073,20 +1112,17 @@ describe(`effect health`, () => { { alias: `row`, isError: true, - error: { - phase: syncMode === `on-demand` ? `initial` : `sync`, - final: true, - }, + error: { phase: `sync`, final: true }, }, ], }) - } finally { - console.error = consoleError - await effect.dispose() - await source.cleanup() } - }, - ) + } finally { + console.error = consoleError + await effect.dispose() + await source.cleanup() + } + }) }) /** @@ -1173,7 +1209,7 @@ describe(`effect request errors`, () => { if (parentMode === `eager`) parentParams.markReady() await flushPromises() - // Model: past the gate, a failed load is a request error. + // Model: after initial-query readiness, a failed load is a request error. outcomes.push(`fail`) writeParent(2) await flushPromises() From 5b9482c75ca95b829e2f4c8e762ce7973f5030a3 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 13:05:00 -0700 Subject: [PATCH 035/106] Revert "test(electric): update oracle tests for post-readiness health law" Its flips expected health errors where persistence now crashes, so redo them against the current core. Co-authored-by: Isaac --- .../tests/electric-oracle.property.test.ts | 56 ++++--------------- 1 file changed, 11 insertions(+), 45 deletions(-) diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index 1e79ecad10..fa62022c93 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -547,14 +547,7 @@ async function runRejectedDurablePrefixWitness( subscriber!([secondMessage, upToDate]) if (acceptedPrefix) { await atCheckpoint(rejectedAttempt.promise, `second durability rejection`) - await vi.waitFor(() => { - // After Collection readiness, a sync failure doesn't change status. - // The failure is reported as a final health error instead. - expect(collection.status).toBe(`ready`) - expect(collection.health.isError).toBe(true) - expect(collection.health.error?.phase).toBe(`sync`) - expect(collection.health.error?.final).toBe(true) - }) + await vi.waitFor(() => expect(collection.status).toBe(`error`)) // A later source callback may arrive after the failed durability turn. // It cannot become part of the accepted prefix or start another write. subscriber!([change(`insert`, 3, `retired`), upToDate]) @@ -580,10 +573,10 @@ async function runRejectedDurablePrefixWitness( expect(durableAttempts[1]?.keys).toContain(2) expect(durableAttempts[1]?.sourceTxids).toEqual([702]) } - // A rejected durability boundary is terminal: the final health error stays - // until cleanup. Status stays ready; only a restart reaches ready again - // after cleanup and preload. Rows are unchanged by the sync failure. - expect(statusAtTerminalCut).toBe(`ready`) + // A rejected durability boundary is terminal: it cannot retry, admit the + // later source commit, or publish a resume claim for an undurable prefix. + // Clearing the accepted prefix on a later rejection is also a wrong rule. + expect(statusAtTerminalCut).toBe(`error`) expect(durableOffset).toBe(acceptedPrefix ? `21_0` : undefined) expect(rowsFromMap(persistedRows)).toEqual(rowsFromMap(acceptedRows)) }, [() => collection.cleanup()]) @@ -2115,7 +2108,6 @@ async function runPersistenceFailureCrashOnlyWitness( const statusAtAdmissionCut = collection.status const publicErrorAtAdmissionCut = collection._lifecycle.getSyncError() - const healthAtAdmissionCut = structuredClone(collection.health) const publicationsBeforeRetiredWork = structuredClone(publications.entries) mockStream.lastOffset = `32_0` const secondMessage = change(`insert`, secondRow.id, secondRow.name) @@ -2132,20 +2124,13 @@ async function runPersistenceFailureCrashOnlyWitness( path?: unknown }) : undefined - const healthAtFailureCut = structuredClone(collection.health) expect({ statusAtAdmissionCut, - healthAtAdmissionCutIsError: healthAtAdmissionCut.isError, - healthAtAdmissionCutPhase: healthAtAdmissionCut.error?.phase, - healthAtAdmissionCutFinal: healthAtAdmissionCut.error?.final, publicErrorNameAtAdmissionCut: publicErrorAtAdmissionCut instanceof Error ? publicErrorAtAdmissionCut.name : undefined, status: collection.status, - healthIsError: healthAtFailureCut.isError, - healthPhase: healthAtFailureCut.error?.phase, - healthFinal: healthAtFailureCut.error?.final, publicErrorName: publicPersistenceError instanceof Error ? publicPersistenceError.name @@ -2163,15 +2148,9 @@ async function runPersistenceFailureCrashOnlyWitness( publicationsBeforeRetiredWork, ), }).toEqual({ - statusAtAdmissionCut: `ready`, - healthAtAdmissionCutIsError: false, - healthAtAdmissionCutPhase: undefined, - healthAtAdmissionCutFinal: undefined, - publicErrorNameAtAdmissionCut: undefined, - status: `ready`, - healthIsError: true, - healthPhase: `sync`, - healthFinal: true, + statusAtAdmissionCut: `error`, + publicErrorNameAtAdmissionCut: `PersistedCollectionDurabilityError`, + status: `error`, publicErrorName: `PersistedCollectionDurabilityError`, hasNamedPersistenceSemantics: true, preservesCause: true, @@ -2470,15 +2449,7 @@ async function runProcessGrammar( : [] expect(rowsFromCollection(runtime.collection)).toEqual(expected) if (runtime.terminalError) { - // After Collection readiness, a sync failure does not change status. - // The error is reported as health metadata instead. - // Before readiness, markError() sets status to 'error' (initial error). - // The collection reaches ready through the initial preload, so - // post-readiness failures stay at 'ready' with health.error.phase='sync'. - expect(runtime.collection.status).toBe(`ready`) - expect(runtime.collection.health.isError).toBe(true) - expect(runtime.collection.health.error?.phase).toBe(`sync`) - expect(runtime.collection.health.error?.final).toBe(true) + expect(runtime.collection.status).toBe(`error`) } if (!runtime.active && runtime.retired) { expect(runtime.collection.status).toBe(`cleaned-up`) @@ -4241,10 +4212,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ]) for (const batch of partition) trace.subscriber(batch) - // After the initial upToDate, the collection is ready. An invalid - // resume transition in a post-readiness batch is a health error, not - // a status change. Status stays ready. - expect(trace.collection.status).toBe(`ready`) + expect(trace.collection.status).toBe(`error`) expect(trace.collection.get(id)).toEqual( expect.objectContaining({ stable: `stable-${id}` }), ) @@ -4338,9 +4306,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { trace.subscriber([change(`insert`, 1, `complete`), upToDate]) for (const batch of partition) trace.subscriber(batch) - // After readiness, an invalid resume in a partition is a health error. - // Status stays ready. - expect(trace.collection.status).toBe(`ready`) + expect(trace.collection.status).toBe(`error`) expect(trace.collection.get(1)).toEqual( expect.objectContaining({ stable: `stable-1` }), ) From a98702f4b67536d3c8a9908fe953d70095bcbc92 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 13:49:41 -0700 Subject: [PATCH 036/106] test(electric): judge a give-up after Collection readiness as final health Invalid resume transitions after Collection readiness keep the rows and the ready status and report a final health error; the reset resume state lets a restart refetch. The process-grammar model now records whether the sync run reached readiness, from its first delivered up-to-date, and predicts status error only for a give-up before it. The partition laws share one helper. The invalid-resume site now reports the health error itself, as the other give-up sites do, instead of relying on core's markError forwarding and its development warning. Mutants killed at an assertion: crashing instead of reporting health, and reporting a health error that is not final (including the process-grammar replay process-grammar-terminal-error). Co-authored-by: Isaac --- .../electric-db-collection/src/electric.ts | 10 +++--- .../tests/electric-oracle.property.test.ts | 34 +++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 03fba8d5c2..3b604e15a9 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -2321,11 +2321,13 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - markError( - new Error( - `Electric resume state referenced an unseen row; a full snapshot is required`, - ), + const error = new Error( + `Electric resume state referenced an unseen row; a full snapshot is required`, ) + // The stream stops here, so after Collection readiness it is a final + // health error; only a restart refetches the snapshot. + if (collection.status === `ready`) reportError(error, { final: true }) + else markError(error) } const processMessages = (messages: Array>): void => { diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index fa62022c93..208e14f4f9 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -1849,6 +1849,18 @@ type ProcessCommand = | { kind: `cleanup`; slot: ProcessSlot } | { kind: `restart`; slot: ProcessSlot } +/** + * After Collection readiness, an Electric sync adapter that gives up reports a + * final health error. Status and rows stay, and only a restart recovers; the + * reset resume state is what lets that restart refetch. + */ +function expectGaveUpAfterReadiness( + collection: Collection, +) { + expect(collection.status).toBe(`ready`) + expect(collection.health.error).toMatchObject({ phase: `sync`, final: true }) +} + type ProcessRuntime = { collection: Collection subscriber?: (messages: Array>) => void @@ -1858,7 +1870,12 @@ type ProcessRuntime = { resumeAvailable: boolean requiresCompleteResume: boolean resettingSnapshot: boolean + // Electric reaches Collection readiness at its first up-to-date. Only a + // delivered snapshot or batch carries one, so the model records it there. + reachedReadiness: boolean terminalError: boolean + // A give-up after readiness is a final health error, not status `error`. + terminalAfterReadiness: boolean active: boolean retired: boolean preloads: Array @@ -2415,7 +2432,9 @@ async function runProcessGrammar( resumeAvailable: false, requiresCompleteResume: false, resettingSnapshot: false, + reachedReadiness: false, terminalError: false, + terminalAfterReadiness: false, active: false, retired: false, preloads: [], @@ -2437,6 +2456,7 @@ async function runProcessGrammar( runtime.subscriber = subscriber runtime.requiresCompleteResume = runtime.resumeAvailable runtime.resettingSnapshot = false + runtime.reachedReadiness = false runtime.terminalError = false runtime.active = true runtime.retired = false @@ -2449,7 +2469,11 @@ async function runProcessGrammar( : [] expect(rowsFromCollection(runtime.collection)).toEqual(expected) if (runtime.terminalError) { - expect(runtime.collection.status).toBe(`error`) + if (runtime.terminalAfterReadiness) { + expectGaveUpAfterReadiness(runtime.collection) + } else { + expect(runtime.collection.status).toBe(`error`) + } } if (!runtime.active && runtime.retired) { expect(runtime.collection.status).toBe(`cleaned-up`) @@ -2524,6 +2548,7 @@ async function runProcessGrammar( runtime.resumeAvailable = false runtime.requiresCompleteResume = false runtime.resettingSnapshot = false + runtime.reachedReadiness = false runtime.terminalError = false return `cleaned up` } @@ -2566,6 +2591,7 @@ async function runProcessGrammar( applyReferenceBatch(runtime.reference, messages) runtime.resettingSnapshot = false runtime.resumeAvailable = true + runtime.reachedReadiness = true return `delivered: snapshot` } @@ -2595,6 +2621,7 @@ async function runProcessGrammar( runtime.subscriber(messages) if (invalidResume) { runtime.terminalError = true + runtime.terminalAfterReadiness = runtime.reachedReadiness runtime.resumeAvailable = false runtime.retiredSubscriber = runtime.subscriber return `delivered: invalid resume` @@ -2602,6 +2629,7 @@ async function runProcessGrammar( applyReferenceBatch(runtime.reference, messages) runtime.resettingSnapshot = false runtime.resumeAvailable = true + runtime.reachedReadiness = true if (txidOutcome) { await expect(txidOutcome).resolves.toBe(true) runtime.seenTxids.add(command.txid) @@ -4212,7 +4240,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ]) for (const batch of partition) trace.subscriber(batch) - expect(trace.collection.status).toBe(`error`) + expectGaveUpAfterReadiness(trace.collection) expect(trace.collection.get(id)).toEqual( expect.objectContaining({ stable: `stable-${id}` }), ) @@ -4306,7 +4334,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { trace.subscriber([change(`insert`, 1, `complete`), upToDate]) for (const batch of partition) trace.subscriber(batch) - expect(trace.collection.status).toBe(`error`) + expectGaveUpAfterReadiness(trace.collection) expect(trace.collection.get(1)).toEqual( expect.objectContaining({ stable: `stable-1` }), ) From 08b064eed9a8f040aa7a24b8f797e171bf12f1e5 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 13:49:44 -0700 Subject: [PATCH 037/106] fix(trailbase-db-collection): report every stream end as a final health error The adapter does not resubscribe, so a stream that closes cleanly also stops change delivery for the sync run. Report a clean close and a stream failure the same way: an initial error while loading, a final health error after Collection readiness, and no log for a sync run that already failed. Remove the unused reportHealthy binding. The stream-end lane and the lifecycle model now observe Collection health instead of console.error. The extended lane failed both histories on the base adapter and the clean close on the previous branch head; a non-final mutant fails 29 lifecycle cells. Co-authored-by: Isaac --- .../trailbase-db-collection/src/trailbase.ts | 32 +++++++------ .../trailbase-db-collection/tests/ORACLE.md | 6 ++- .../tests/lifecycle-oracle.property.test.ts | 45 ++++++++++++++----- .../tests/trailbase.test.ts | 27 ++++++----- 4 files changed, 75 insertions(+), 35 deletions(-) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 9f143d4b40..6a75b7e283 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -192,7 +192,6 @@ export function trailBaseCollectionOptions< markReady, markError, reportError, - reportHealthy: _reportHealthy, collection, } = params let cancelled = false @@ -406,6 +405,15 @@ export function trailBaseCollectionOptions< } } + function reportStreamEnd(error: unknown, closed = false) { + if (cancelled) return + if (collection.status === `loading`) markError(error) + else if (collection.status === `ready`) + reportError(error, { final: true }) + // A failed sync run already reported why it ended. + else if (!closed) console.error(`TrailBase subscription failed`, error) + } + async function start() { let reader: ReadableStreamDefaultReader | undefined try { @@ -430,18 +438,16 @@ export function trailBaseCollectionOptions< subscribedReader.releaseLock() if (eventReader === subscribedReader) eventReader = undefined }) - .catch((error: unknown) => { - if (!cancelled) { - if (collection.status === `loading`) { - markError(error) - } else if (collection.status === `ready`) { - // Post-readiness stream failure: adapter does not resubscribe - reportError(error, { final: true }) - } else { - console.error(`TrailBase subscription failed`, error) - } - } - }) + // The adapter does not resubscribe, so any end of the stream it + // did not cancel stops change delivery for this sync run. + .then( + () => + reportStreamEnd( + new Error(`TrailBase change stream closed`), + true, + ), + (error: unknown) => reportStreamEnd(error), + ) listening = true settleListener() diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index cd46d83e63..c2def204f5 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -81,7 +81,11 @@ The same interpreter runs a fixed corpus and generated histories. - The adapter subscribes once per sync run and does not resubscribe. After the stream closes or fails, the Collection keeps its rows and stays ready, a later service change does not reach it, and a later on-demand load lists - the service's current rows without waiting. Two histories make this limit + the service's current rows without waiting. Collection health holds a final + health error from the end onward, with a stream failure as its cause, and + only a restart clears it. Reconnecting is left to the TrailBase provider. + A mutant that reports the end as a health error that is not final fails 29 + lifecycle cells. Two histories make this limit executable; they do not claim it is the right product behavior. They pin one stream end followed by one change and one load. A page whose newer event never arrives because the stream ended keeps the value the fence diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index b2ae460952..c575d146ba 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -11,8 +11,10 @@ import type { Event, ListResponse } from 'trailbase' * * An eager sync run needs both its list and stream before readiness. An * on-demand sync run does not claim an initial list. Stream events update an independent - * key/value relation. Startup failure rejects; later failure keeps the last - * rows and reports the error. Cleanup clears rows, cancels work, and fences + * key/value relation. Startup failure rejects; any later end of the stream + * that the adapter did not cancel keeps the last rows and the `ready` status + * and reports a final health error, because the adapter does not resubscribe. + * A failure is that error's `cause`. Cleanup clears rows, cancels work, and fences * late results from prior sync runs after restart. * * A controlled RecordApi and native ReadableStream provide explicit gates for @@ -351,11 +353,17 @@ async function checkLifecycle( assertNoTimers() const isFailure = plan.ending === `read-error` || plan.ending === `parse-error` - if (isFailure && !plan.immediate) { - expectedReports.push({ - epoch, - args: [`TrailBase subscription failed`, failure], - }) + if (!plan.immediate && plan.ending !== `cleanup`) { + expect(collection.health, `${epoch}:${plan.ending} health`).toEqual( + { + isError: true, + error: expect.objectContaining({ + phase: `sync`, + final: true, + ...(isFailure ? { cause: failure } : {}), + }), + }, + ) } assertReports() if (plan.ending === `cleanup` || plan.ending === `parse-error`) @@ -1159,8 +1167,12 @@ it.each(heldPlans)( * once per sync run and does not resubscribe. After the stream ends, the * Collection keeps its rows and stays ready, a later service change does not * reach it, and a later on-demand load does not wait for a new subscription. - * The load lists the service's current rows. This lane makes that limit - * executable; it does not claim that the limit is the right product behavior. + * The load lists the service's current rows. Because the sync adapter stops + * receiving changes, it reports a final health error, so an application can + * see that the rows can grow stale; only a restart subscribes again. This + * lane makes that limit executable; reconnecting is left to the TrailBase + * provider, and the lane does not claim the limit is the right product + * behavior. * * Reference: the same service model. The prediction after the stream ends is * the rows that the last delivered event or page established. @@ -1172,10 +1184,11 @@ it.each(heldPlans)( * flight when the stream ends, or cleanup between them. * * Checkpoints: after the stream ends, after the service change, and after the - * later load. Each compares rows, status, and subscribe calls. + * later load. Each compares rows, status, and subscribe calls. After the end, + * Collection health holds one final health error, and nothing clears it. */ it.each([`close`, `read-error`] as const)( - `keeps rows and does not resubscribe after the stream ends: %s`, + `keeps rows, reports a final health error, and does not resubscribe after the stream ends: %s`, async (ending) => { const service = new Map(raceServiceRows.map((row) => [row.id, row])) let controller!: ReadableStreamDefaultController @@ -1214,6 +1227,13 @@ it.each([`close`, `read-error`] as const)( await turn() expect(collection.status, `${ending} status after the end`).toBe(`ready`) expect(rows(), `${ending} rows after the end`).toEqual(established) + const finalHealth = { + isError: true, + error: expect.objectContaining({ phase: `sync`, final: true }), + } + expect(collection.health, `${ending} health after the end`).toEqual( + finalHealth, + ) service.set(1, { id: 1, data: `v2` }) await turn() @@ -1229,6 +1249,9 @@ it.each([`close`, `read-error`] as const)( expect(rows(), `${ending} a later load lists current rows`).toEqual( sortedRows(service.values()), ) + expect(collection.health, `${ending} health after the load`).toEqual( + finalHealth, + ) } finally { await collection.cleanup() reported.mockRestore() diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index 63448f0988..b41cec4926 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -268,10 +268,15 @@ describe(`TrailBase Integration`, () => { await collection.preload() controller.enqueue({ Insert: { id: 1, updated: 0, data: `invalid` } }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(reported).toHaveBeenCalledExactlyOnceWith( - `TrailBase subscription failed`, - failure, - ) + // After Collection readiness the stream failure is a final health + // error, not a log line; the rows and status stay. + expect(reported).not.toHaveBeenCalled() + expect(collection.status).toBe(`ready`) + expect(collection.health.error).toMatchObject({ + phase: `sync`, + final: true, + cause: failure, + }) expect(cancel).toHaveBeenCalledOnce() expect(stream.locked).toBe(false) await collection.cleanup() @@ -327,12 +332,14 @@ describe(`TrailBase Integration`, () => { expect(stripState(collection.state)).toEqual(expectedRows) expect(recordApi.subscribe).toHaveBeenCalledOnce() expect(recordApi.list).toHaveBeenCalledOnce() - if (ending === `error`) { - expect(reported).toHaveBeenCalledExactlyOnceWith( - `TrailBase subscription failed`, - failure, - ) - } else expect(reported).not.toHaveBeenCalled() + // The adapter does not resubscribe, so every end of the stream is a + // final health error; a stream failure is its cause. + expect(reported).not.toHaveBeenCalled() + expect(collection.health.error).toMatchObject({ + phase: `sync`, + final: true, + ...(ending === `error` ? { cause: failure } : {}), + }) await collection.cleanup() await new Promise((resolve) => setTimeout(resolve, 0)) From 77cf7e71580adc6a763357d9bca4da2a9295d139 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 13:51:18 -0700 Subject: [PATCH 038/106] revert(rxdb-db-collection): drop the unreachable post-readiness branch start() ends with markReady(), so its rejection handler only runs before Collection readiness and the added ready branch could never run. Record in PLAN that V10 is closed by inspection and that the unhandled rxCollection.$ error remains open with its needed witness. Co-authored-by: Isaac --- .../designs/source-failure-and-health/PLAN.md | 12 +++++++++--- packages/rxdb-db-collection/src/rxdb.ts | 14 +------------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 729129f9d4..55249b3f59 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -148,9 +148,15 @@ names and shapes. - [ ] Update the stream-end lane in `tests/lifecycle-oracle.property.test.ts` (added in #2126) from "rows stay, later changes are not delivered" to also expect a final health error. -- [ ] **RxDB** (`rxdb-db-collection/src/rxdb.ts`, 3 sites): post-readiness - failures call `reportError`. Observe what happens to pending loads on a - final error (evidence gap V10). +- [x] **RxDB** (`rxdb-db-collection/src/rxdb.ts`): no change. V10 is + closed by inspection: RxDB is eager and has no `loadSubset`, so a final + error has no pending subset loads; its only failure path is the initial + fetch, which calls `markError` before Collection readiness and rejects + readiness waiters under the core initial-error law. Open: the + `rxCollection.$` subscription has no error handler, so an RxDB stream + failure after readiness is not reported at all. Reporting it as a final + health error needs an RxDB fault a test can drive; owner: RxDB adapter, + witness: an observable error after readiness. - [ ] **Persistence** (`db-sqlite-persistence-core/src/persisted.ts`, 19 sites): - [ ] forward upstream Collection health; keep local storage failures on diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index 4169f5c6f2..a5140b2031 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -126,16 +126,7 @@ export function rxdbCollectionOptions( type SyncParams = Parameters[`sync`]>[0] const sync: SyncConfig = { sync: (params: SyncParams) => { - const { - begin, - write, - commit, - markReady, - markError, - reportError, - reportHealthy: _reportHealthy, - collection, - } = params + const { begin, write, commit, markReady, markError, collection } = params let initialFetchComplete = false async function initialFetch() { @@ -290,9 +281,6 @@ export function rxdbCollectionOptions( stopOngoingFetch() if (collection.status === `loading`) { markError(error) - } else if (collection.status === `ready`) { - // Post-readiness fetch failure: sync adapter does not retry - reportError(error, { final: true }) } }) From 446a69e2a7222c9a1e72b43f37e653a84d07e8e6 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 14:05:49 -0700 Subject: [PATCH 039/106] fix(powersync-db-collection): report a tracking outage as Collection health After Collection readiness, a failed tracking rebuild is a health error that is not final, because the adapter rebuilds again on its retry timer and on new demand. A rebuild whose baseline applied reports healthy. A local flag now marks the outage, replacing the old error status as the signal to reconcile rows that left during the gap. This replaces the previous commit's markReady() on any loading rebuild, which declared readiness early and broke 23 tracking tests. The outage tests observe health; the hand-built sync params gain the health callbacks. Mutants killed at an assertion: markError on a failed rebuild, a rebuild that never reports healthy, and no outage reconciliation. Co-authored-by: Isaac --- .../powersync-db-collection/src/powersync.ts | 48 ++++++++----------- .../tests/on-demand-sync.test.ts | 17 ++++++- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 638a4bcf3e..ffc9fcf719 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -742,6 +742,8 @@ function createPowerSyncCollectionConfig< // One reconciliation owns every queued revision so callers cannot // settle against a stale trigger configuration. + // Set while a failed rebuild may have left the source untracked. + let trackingOutage = false const reconcileTracking = async (): Promise => { while (!stopped && reconciledTrackingRevision !== trackingRevision) { const revision = trackingRevision @@ -756,12 +758,11 @@ function createPowerSyncCollectionConfig< if (!isCurrent()) return const active = activeWhereExpressions() - // Tracking was absent during an error. Positive baseline rows + // Tracking was absent during an outage. Positive baseline rows // alone cannot reveal deletes or predicate exits from that gap. - const missing = - collection.status === `error` - ? new Set(collection.keys()) - : undefined + const missing = trackingOutage + ? new Set(collection.keys()) + : undefined if (active.length > 0) { const combinedWhere = active.length === 1 @@ -817,17 +818,10 @@ function createPowerSyncCollectionConfig< await Promise.all(appliedReceipts) if (isCurrent()) { reconciledTrackingRevision = revision - // Replacing the trigger alone is not recovery: its baseline - // writes must also be applied before the source is ready again. - if (collection.status === `error`) { - // After markError, recovery requires cleanup() then preload(). - // Do nothing here; the error stays final. - } else if (collection.status === `loading`) { - // Before initial-query readiness, move to ready - markReady() - } else if (collection.status === `ready`) { - // After initial-query readiness, a successful rebuild reports - // recovery through Collection health. + // Replacing the trigger alone does not restore change tracking: + // its baseline writes must also apply before sync works again. + if (trackingOutage) { + trackingOutage = false reportHealthy() } } @@ -840,19 +834,15 @@ function createPowerSyncCollectionConfig< while (!stopped && reconciledTrackingRevision !== trackingRevision) { await (rebuildPromise ??= reconcileTracking() .catch((error) => { - // A rebuild may already have removed every active diff trigger. - // Do not leave healthy consumers ready against a stale source. - if (!stopped) { - if (collection.status === `loading`) { - // Initial load failure is final for the attempt - markError(error) - } else if (collection.status === `ready`) { - // Post-ready failures are reported as health issues (not final; - // the adapter may retry on new demand or automatic reconciliation) - reportError(error, { final: false }) - } - // Other statuses (error, idle, cleaned-up) don't report - } + // A rebuild may already have removed every active diff trigger, + // so rows can grow stale. Later demand rebuilds again, so after + // Collection readiness this is a health error that is not final. + if (stopped) { + // Cleanup owns the outcome. + } else if (collection.status === `ready`) { + trackingOutage = true + reportError(error, { final: false }) + } else markError(error) throw error }) .finally(() => { diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 38d8c7cfc8..458f89fb03 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -2698,6 +2698,8 @@ describe(`On-Demand Sync Mode`, () => { commit, markReady: vi.fn(), markError: vi.fn(), + reportError: vi.fn(), + reportHealthy: vi.fn(), truncate: vi.fn(), } as never) const loadSubset = @@ -3282,7 +3284,16 @@ describe(`On-Demand Sync Mode`, () => { .spyOn(db.triggers, `createDiffTrigger`) .mockRejectedValueOnce(new Error(`release rebuild failed`)) collection._sync.unloadSubset(first) - await vi.waitFor(() => expect(collection.status).toBe(`error`)) + // The outage keeps the rows and the ready status. The adapter retries + // the rebuild, so it reports a health error that is not final. + await vi.waitFor(() => + expect(collection.health.error).toMatchObject({ + phase: `sync`, + final: false, + message: `release rebuild failed`, + }), + ) + expect(collection.status).toBe(`ready`) if (change === `delete` || change === `release-last`) { await db.execute(`DELETE FROM products WHERE id = 'retained'`) } else if (change === `predicate exit`) { @@ -3296,7 +3307,9 @@ describe(`On-Demand Sync Mode`, () => { await vi.waitFor(() => expect(trigger.mock.calls.length).toBeGreaterThan(1), ) - await vi.waitFor(() => expect(collection.status).toBe(`ready`)) + // A rebuild whose baseline applied reports healthy again. + await vi.waitFor(() => expect(collection.health.isError).toBe(false)) + expect(collection.status).toBe(`ready`) expect([...collection.keys()]).toEqual( change === `unchanged` ? [`retained`] : [], ) From 17447cb6ab0aa9fbbf9508eb2b57cb882726fded Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 14:05:52 -0700 Subject: [PATCH 040/106] fix(query-db-collection): make Collection health mirror utils.lastError After Collection readiness, the failure that sets utils.lastError is a health error that is not final, since TanStack Query fetches again, and the clear that resets utils.lastError reports healthy. The previous commit cleared health only after a failed result application for the same key, so a failed refetch stayed a health error forever. The new load-subset oracle witness states the mirror rule and fails on the base adapter and on the previous commit. A refused cache rotation is a persistence failure, so its test expects a crash again. Test names and comments now say restart where recovery needs one. Co-authored-by: Isaac --- packages/query-db-collection/src/query.ts | 42 +++----- .../load-subset-lifecycle-oracle.test.ts | 101 ++++++++++++++---- .../tests/ownership-lifecycle.oracle.test.ts | 9 +- 3 files changed, 100 insertions(+), 52 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index cb7cbb6a31..d4b25e9ed5 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -430,12 +430,11 @@ class QueryCollectionUtilsImpl implements QueryCollectionUtils { await this.refetch({ throwOnError: true }) } - // Getters for error state - // These track query-level errors (per query, not per collection). They are - // separate from Collection.health. When status is ready, query errors appear - // only in health; when status is error, they appear here too as the initial - // load failed. After ready, a failed query fetch sets health as a request - // error and keeps the last ready data while tracking in lastError/errorCount. + // Getters for error state. They cover every Query this Collection observes: + // a failed fetch or result application sets them, and a later successful + // application clears them. After Collection readiness, Collection health + // mirrors them: the same failure is a health error that is not final, since + // TanStack Query fetches again, and the same clear reports healthy. public get lastError() { return this.state.lastError } @@ -1192,6 +1191,11 @@ export function queryCollectionOptions( const observedErrorUpdates = new WeakMap() let errorRevision = 0 const failedResultApplications = new Map() + const clearLastError = () => { + state.lastError = undefined + state.errorCount = 0 + if (collection.status === `ready`) reportHealthy() + } const exceptionalResultSettlements = new WeakMap< QueryObserverResult, ExceptionalResultSettlement @@ -2688,13 +2692,9 @@ export function queryCollectionOptions( unownedDirectWritePositions.delete(key) }) if (!signal?.aborted) { - // A successful refetch after an initial load failure does not revive - // the Collection. The failure is final; recovery needs cleanup() then - // preload(). After initial-query readiness, successful results report - // health recovery in trackResultApplication. - if (collection.status !== `error`) { - markReady() - } + // A later successful refetch does not revive a failed initial + // load; only a restart does. + if (collection.status !== `error`) markReady() } } const accepted = whenSyncAccepted(applied) @@ -2778,16 +2778,10 @@ export function queryCollectionOptions( void application.then( () => { if (finish()) { - const hadError = failedResultApplications.has(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) // Applying cached data during a fetch does not establish recovery. if (!isFetching && applicationErrorRevision === errorRevision) { - state.lastError = undefined - state.errorCount = 0 - // Report recovery after a problem if the collection is ready - if (hadError && collection.status === `ready`) { - reportHealthy() - } + clearLastError() } } }, @@ -2801,9 +2795,6 @@ export function queryCollectionOptions( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) - // Report post-ready failures as health issues. Not final because query - // observers will retry via refetchInterval, refetchOnWindowFocus, or - // manual refetch() calls; the observer may succeed on the next attempt. if (collection.status === `ready`) { reportError(error, { final: false }) } @@ -2849,8 +2840,7 @@ export function queryCollectionOptions( resultApplicationControllers.delete(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) if (!result.isFetching && currentErrorRevision === errorRevision) { - state.lastError = undefined - state.errorCount = 0 + clearLastError() } } return @@ -3183,8 +3173,6 @@ export function queryCollectionOptions( if (collection.status === `loading`) { markError(result.error) } else if (collection.status === `ready`) { - // Report post-ready failures as health issues. Not final because - // observers will retry on interval/reconnect/manual refetch. reportError(result.error, { final: false }) } } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index 92ef08ab75..d6b0aa1db3 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -1158,6 +1158,76 @@ async function expectForeignOwnerReleaseKeepsInFlightSignal(): Promise { } } +/** + * After Collection readiness, Collection health mirrors `utils.lastError`. + * A failed refetch sets both: the health error is not final, because TanStack + * Query fetches again, and the status and rows stay. The next successful + * application clears both. The model is that one rule; it does not read the + * adapter's per-key bookkeeping. + */ +async function expectRefetchFailureHealthFollowsLastError(): Promise { + const error = new Error(`refetch failed`) + const queryClient = createQueryClient() + const id = `load-subset-health-${collectionSequence++}` + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const queryFn = vi + .fn() + .mockResolvedValueOnce([{ id: `first` }]) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce([{ id: `second` }]) + const collection = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + retry: false, + }), + ) + const observe = () => ({ + status: collection.status, + lastError: collection.utils.lastError, + health: collection.health.error && { + phase: collection.health.error.phase, + final: collection.health.error.final, + cause: collection.health.error.cause, + }, + rows: collection.toArray.map((row) => row.id), + }) + try { + await collection.preload() + expect(observe()).toEqual({ + status: `ready`, + lastError: undefined, + health: null, + rows: [`first`], + }) + + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.utils.lastError).toBe(error)) + expect(observe()).toEqual({ + status: `ready`, + lastError: error, + health: { phase: `sync`, final: false, cause: error }, + rows: [`first`], + }) + + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.utils.lastError).toBeUndefined()) + expect(observe()).toEqual({ + status: `ready`, + lastError: undefined, + health: null, + rows: [`second`], + }) + } finally { + loggedError.mockRestore() + await collection.cleanup() + queryClient.clear() + } +} + async function expectInitialQueryFailureStatus(): Promise { const error = new Error(`initial query failed`) const queryClient = createQueryClient() @@ -1167,7 +1237,7 @@ async function expectInitialQueryFailureStatus(): Promise { .fn() .mockRejectedValueOnce(error) .mockResolvedValueOnce([{ id: `recovered` }]) - .mockResolvedValueOnce([{ id: `recovered` }]) // for cleanup+preload restart + .mockResolvedValueOnce([{ id: `recovered` }]) const collection = createCollection( queryCollectionOptions({ id, @@ -1204,19 +1274,14 @@ async function expectInitialQueryFailureStatus(): Promise { throw new TraceAssertionError(0, caught) } - // With the new design, an initial error is final for its sync attempt. - // clearError() triggers a refetch, but the status stays error until - // cleanup() + preload() starts a fresh sync attempt. - // First, note that the refetch will succeed and the data will be available - // in lastError tracking, but status stays error. + // An initial error is final for its sync run. A successful refetch clears + // utils.lastError and applies its rows, but only a restart reaches ready. await collection.utils.clearError() - // After a successful refetch from an error state, status stays error, - // but utils.lastError clears and we can see the data await vi.waitFor(() => { expect(collection.utils.lastError).toBeUndefined() expect(collection.get(`recovered`)).toBeDefined() }) - // To fully recover to ready status, cleanup and restart + expect(collection.status).toBe(`error`) await collection.cleanup() await live.cleanup() await collection.preload() @@ -1331,11 +1396,8 @@ async function expectEveryFailedSourceToRecover(): Promise { await expect(preload).rejects.toThrow() expect(live.status).toBe(`error`) - // With the new design, recovery from initial errors requires cleanup() + preload(). - // After markError(), you cannot call markReady() in the same sync run. - // Instead, recovery is via cleanup() + preload() which starts a fresh sync attempt. - - // Restart left + // Each failed source recovers only through a restart, and the dependent + // stays in error until it restarts after every source is ready. await left.collection.cleanup() const leftPreload = left.collection.preload() left.recover({ id: `shared` }) @@ -1344,14 +1406,11 @@ async function expectEveryFailedSourceToRecover(): Promise { expect(right.collection.status).toBe(`error`) expect(live.status).toBe(`error`) - // Restart right await right.collection.cleanup() const rightPreload = right.collection.preload() right.recover({ id: `shared` }) await rightPreload expect(right.collection.status).toBe(`ready`) - - // Live query should now be ready after both sources recover await live.cleanup() const livePreload2 = live.preload() await livePreload2 @@ -2582,7 +2641,11 @@ describe(`loadSubset lifecycle oracle`, () => { await expectReleasedRefetchRejects() }) - it(`reports an initial query failure and recovers after a successful refetch`, async () => { + it(`mirrors utils.lastError into Collection health after readiness`, async () => { + await expectRefetchFailureHealthFollowsLastError() + }) + + it(`keeps an initial query failure until a restart, even after a successful refetch`, async () => { await expectInitialQueryFailureStatus() }) @@ -2590,7 +2653,7 @@ describe(`loadSubset lifecycle oracle`, () => { await expectLateDependentObservesInitialFailure() }) - it(`recovers a dependent only after every failed source recovers`, async () => { + it(`restarts a dependent only after every failed source restarts`, async () => { await expectEveryFailedSourceToRecover() }) diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index d26168eecc..cc042a1b6c 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -13127,12 +13127,9 @@ it(`reports a failed Query cache rotation as a terminal Collection error`, async const outcome = recover() await rotationEntered.promise await expect(outcome).rejects.toBe(failure) - // Cache rotation failure happens after readiness. With the new design, post-ready - // failures don't change status; they appear in health. Status stays ready. - expect(collection.status).toBe(`ready`) - expect(collection.health.isError).toBe(true) - expect(collection.health.error?.message).toMatch('rotation refused') - // Subsequent operations fail because of the health error + // A refused rotation is a persistence failure, so the Collection crashes + // even after Collection readiness, and later demand rejects with its cause. + expect(collection.status).toBe(`error`) await expect(collection._sync.loadSubset({ limit: 2 })).rejects.toBe(failure) }) From c6c9c8a3411aa123934e856444467d922879d3e9 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 14:18:56 -0700 Subject: [PATCH 041/106] docs: describe Collection health, the error tree, and restarts Rewrite the error-handling guide around its two questions: whether data can be shown, from status and isError, and whether it is still kept up to date, from the errors tree. Document crashes, request errors, Effects, Suspense, and restart. Remove the claim that a successful refetch recovers an initial error. Add the reporting rules to the adapter guide, an error-tree section to the live-queries guide, and health notes for Electric, query-db, IndexedDB, and SQLite persistence. Co-authored-by: Isaac --- docs/collections/electric-collection.md | 13 +- docs/collections/indexed-db-collection.md | 2 +- docs/collections/query-collection.md | 8 + docs/guides/collection-options-creator.md | 61 ++++++- docs/guides/error-handling.md | 203 ++++++++++++++++++---- docs/guides/live-queries.md | 39 ++++- docs/guides/sqlite-persistence.md | 4 +- 7 files changed, 285 insertions(+), 45 deletions(-) diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 0b56f39712..8ed61225fc 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -407,9 +407,16 @@ rather than when its durable-cache notification arrives. Outside scoped recovery, coordinator invalidation retains its normal behavior. An eager or progressive resume cannot apply a partial update to an unknown row. -The adapter rejects that batch, enters an error state, and records a reset so the -next sync starts from a full snapshot. This does not silently retry the failed -stream. Complete updates from an explicit `replica: 'full'` stream remain valid. +The adapter rejects that batch, stops the stream, and records a reset so the +next sync starts from a full snapshot. Before the Collection is ready, this is +an initial error; after it, the rows stay and the Collection reports a final +health error in `collection.health` and in each live query's `errors` tree. +This does not silently retry the failed stream; a restart refetches. + +While the Electric stream retries a failed request, the Collection reports +nothing: it stays `loading` before its first up-to-date, and keeps its health +afterward. When the stream gives up, the failure is final: an initial error +before readiness, or a final health error after it. Complete updates from an explicit `replica: 'full'` stream remain valid. On-demand streams can observe updates outside their loaded subsets; unknown partial rows are ignored, while transaction acknowledgement evidence is retained. Outside scoped recovery, complete rows published by persistence reloads or diff --git a/docs/collections/indexed-db-collection.md b/docs/collections/indexed-db-collection.md index 33f1956993..5032c221b2 100644 --- a/docs/collections/indexed-db-collection.md +++ b/docs/collections/indexed-db-collection.md @@ -276,7 +276,7 @@ db.close() ``` Connections created by `createIndexedDB` close automatically on native -`versionchange`. Unexpected native connection closure also notifies their managed Collections. In both cases, active Collections enter `error`. +`versionchange`. Unexpected native connection closure also notifies their managed Collections. In both cases, active Collections crash: they enter `error` even after they were ready, and so does every live query and Effect over them. The descriptor's `db.close()` method has the same local effect. Their last published rows remain available. Recreate affected Collections with a new instance using the current database version. Calling the raw `db.db.close()` diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index ef022a5e5d..a23026416b 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -685,6 +685,14 @@ The collection provides these utility methods via `collection.utils`: - `clearError()`: Refetch with errors enabled; retain the recorded error until a successful result applies to the Collection - Uses the same phase-dependent boundary as `refetch()`. During a mutation handler, the promise can resolve at the fetch boundary before the result applies and clears the error. +`utils.lastError`, `utils.isError`, and `utils.errorCount` cover every Query the +Collection observes. After the Collection is ready, `collection.health` and each +live query's `errors` tree mirror them: a failed fetch or result application is +a health error that is not final, because TanStack Query fetches again, and the +next successful application clears both. A successful refetch does not move a +Collection whose initial load failed back to `ready`; restart it with +`cleanup()` and `preload()`. + ## Direct Writes Direct writes are intended for scenarios where the normal query/mutation flow doesn't fit your needs. They write directly to the synced data store and bypass the optimistic update system. Their Query cache behavior depends on the collection's sync mode. diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index 575523630f..6e3766ca26 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -147,9 +147,8 @@ const sync: SyncConfig['sync'] = (params) => { } catch (error) { if (initialSyncAbort.signal.aborted) return console.error('Initial sync failed:', error) - // No usable initial snapshot exists. - // Only initial startup owns collection readiness. A later refetch - // failure must keep the last ready snapshot usable. + // No usable initial snapshot exists, and this example does not retry. + // After readiness, report failures with reportError() instead. if (collection.status === 'loading') markError(error) } } @@ -174,8 +173,56 @@ The sync process follows this lifecycle: 1. **begin()** - Start collecting changes 2. **write()** - Add changes to the pending transaction (buffered until commit) 3. **commit()** - Apply all changes atomically to the collection state -4. **markReady()** - Signal that a usable initial or recovered snapshot exists -5. **markError(error?)** - Signal that initial sync failed before producing a usable snapshot; pass the cause so readiness waits reject with it +4. **markReady()** - Signal that a usable initial snapshot exists +5. **markError(error?)** - Signal that you gave up on the initial sync before producing a usable snapshot; pass the cause so readiness waits reject with it. It is final for the sync run: `markReady()` afterward throws, and only a restart starts over +6. **reportError(error, { final })** - After the Collection is ready, report a sync problem without changing its status or rows. Pass `final: true` when you stop trying; only a restart clears a final error +7. **reportHealthy()** - After the Collection is ready, report that sync works again. It clears an error that is not final + +### Reporting failures + +Users see two separate signals: whether data can be shown, and whether it is +still kept up to date. Follow these rules so both stay truthful: + +1. While you retry the initial sync, call nothing. The Collection stays + `loading`. +2. Call `markError(error)` only when you give up before the first + `markReady()`. Do not call `markReady()` after it. +3. After the first `markReady()`, call `reportError(error, { final })` for + problems and `reportHealthy()` when sync works again. Never discard rows + because sync failed. +4. Stay silent while a retry is in progress, and report `final: true` only + when you stop for good. + +```ts +sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy }) => { + let ready = false + const stream = connect({ + onData: (rows) => { + begin() + rows.forEach((row) => write({ type: "insert", value: row })) + commit() + if (!ready) { + markReady() + ready = true + } else { + reportHealthy() + } + }, + onError: (error, { willRetry }) => { + if (!ready) { + if (!willRetry) markError(error) + } else { + reportError(error, { final: !willRetry }) + } + }, + }) + return () => stream.close() +} +``` + +Calling `markError(error)` after the first `markReady()` still works while you +migrate: it is treated as `reportError(error, { final: true })` and logs a +development warning. `commit()` accepts the transaction and returns `true`. An accepted transaction always applies, in commit order. While an optimistic transaction is persisting, @@ -983,8 +1030,8 @@ const collection = createCollection( ## Best Practices -1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `markError(error)` if initial sync fails -2. **Recover explicitly** - After an error, call `markReady()` only when a later sync has produced a usable snapshot +1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `markError(error)` when you give up on the initial sync +2. **Report health after readiness** - Use `reportError()` and `reportHealthy()` once the Collection is ready; recovery from an initial error is a restart, not a later `markReady()` 3. **Clean up resources** - Return a cleanup function from sync to prevent memory leaks 4. **Batch operations** - Use begin/commit to batch multiple changes for better performance 5. **Race Conditions** - Start listeners before initial fetch and buffer events diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 58a3c9fa2f..d548558210 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -183,35 +183,157 @@ are waiting for replay with `AbortError`. ## Collection Status and Error States -Collections track their status and transition between states: +A Collection and every live query over it answer two separate questions: + +| Question | Where to look | Changes when | +| --- | --- | --- | +| Can this data be shown, or must the query restart? | `status`, `isReady`, `isError` | During the initial load of a sync run, or when a Collection crashes | +| Is this data still kept up to date? | `errors` (the error tree) | When a sync adapter reports a problem or recovers, or a request for more data fails | ```tsx import { useLiveQuery } from "@tanstack/react-db" const TodoList = () => { - const { data, status, isError, isLoading, isReady } = useLiveQuery( - (query) => query.from({ todos: todoCollection }) + const { data, isError, isLoading, errors } = useLiveQuery((query) => + query.from({ todos: todoCollection }) ) - if (isError) { - return
Collection is in error state
- } - - if (isLoading) { - return
Loading...
- } + if (isLoading) return
Loading...
+ if (isError) return
Todos failed to load
- return
{data?.map(todo =>
{todo.text}
)}
+ return ( + <> + {errors?.hasErrors &&
Todos may be out of date.
} + {data.map((todo) => ( +
{todo.text}
+ ))} + + ) } ``` Collection status values: - `idle` - Not yet started - `loading` - Loading initial data -- `initialCommit` - Processing initial data - `ready` - Ready for use -- `error` - In error state -- `cleaned-up` - Cleaned up and no longer usable +- `error` - The sync run failed; only a restart recovers +- `cleaned-up` - Cleaned up; a new preload or subscriber restarts it + +### The initial load + +Until a Collection has its first usable snapshot, its status is `loading`. +While its sync adapter retries, it stays `loading`. When the sync adapter gives +up, the status becomes `error`, readiness waits such as `preload()` reject with +the original cause, and every live query and Effect that depends on the +Collection fails too. + +An initial error is final for its sync run. TanStack DB does not retry it, and +a later successful sync does not move the Collection back to `ready`. To try +again, restart the query: + +```ts +await todosQuery.cleanup() +await todosQuery.preload() +``` + +### After the first ready + +Once the data has loaded, it stays. When the sync adapter later loses its +connection or a refresh fails, `status` stays `ready` and the rows stay, even +as they grow stale. The problem appears in `errors` and in +`collection.health` instead. The sync adapter clears it when sync works again, +unless the error is final: a final error means the sync adapter stopped +trying, and only a restart clears it. + +> **Important:** `isError` does not report these problems. To show a "this data +> may be out of date" banner, check `errors.hasErrors`. + +### When a Collection crashes + +A few failures leave a Collection unable to serve reads or accept writes: a +closed IndexedDB connection, a failed write to local SQLite persistence, or an +internal invariant failure. The Collection then moves to `error` even after it +was ready, and so does every live query and Effect above it. These failures are +rare, and they mean storage is broken. Recover the same way as from an initial +error: clean up and restart. + +### The error tree + +Every live query result includes `errors`. It has the same shape for a given +query: one node for the live query and one node for each source alias, in query +order. Healthy nodes are present too, with `isError: false`. A disabled query +has no query definition, so its `errors` is `undefined`. + +```ts +type ErrorTreeNode = { + alias: string // the query alias, or "root" + collectionId: string + kind: "source" | "live-query" | "include" + status: CollectionStatus + isError: boolean // this node has a problem now + hasErrors: boolean // this node or any node below it + error: CollectionErrorInfo | null + instances?: { total: number; inError: number } // include nodes only + sources: Array +} + +type CollectionErrorInfo = { + phase: "initial" | "sync" | "request" + final: boolean // the sync adapter stopped trying; only a restart clears it + name: string + message: string + code?: string + at: number // epoch milliseconds + cause: unknown // the original value; omitted from JSON +} +``` + +The phases: + +- `initial`: the Collection's sync run failed, by an initial error or a crash. + Its `status` is `error`. +- `sync`: the sync adapter reported a problem after the Collection was ready. +- `request`: a request for more data, such as the next page or a newly + joined row, failed after the query was ready. The source Collection stays + healthy. The error clears when that data loads, or when the query stops + needing it. + +A Collection used twice in one query, for example in a self-join, has one node +per alias. An include has one node for all of its per-row child Collections, +with `instances` counting how many are in error. A nested live query's node +carries its own sources. + +The tree keeps its object identity until something in it changes, so you can +pass it to `useMemo` or compare it with `===`. Apart from `cause`, it is plain +data that you can log or send: + +```ts +useEffect(() => { + if (errors?.hasErrors) reportToMonitoring(JSON.parse(JSON.stringify(errors))) +}, [errors]) +``` + +Server-side rendering does not transfer `errors`; the client builds its own +tree from its own sync. + +### Effects + +An Effect follows the same rules. A source that fails its initial load, or +that crashes, disposes the Effect through `onSourceError`. Any other problem +after the Effect's initial load does not dispose it. Subscribe to it instead: + +```ts +createEffect({ + query: (q) => q.from({ order: orders }), + onBatch: (events) => { + /* ... */ + }, + onHealthChange: (errors) => { + if (errors.hasErrors) pauseAutomations() + else resumeAutomations() + }, +}) +``` ### Using Suspense and Error Boundaries (React) @@ -223,13 +345,19 @@ import { Suspense } from "react" import { ErrorBoundary } from "react-error-boundary" const TodoList = () => { - // No need to check status - Suspense and ErrorBoundary handle it - const { data } = useLiveSuspenseQuery( - (query) => query.from({ todos: todoCollection }) + const { data, errors } = useLiveSuspenseQuery((query) => + query.from({ todos: todoCollection }) ) // data is always defined here - return
{data.map(todo =>
{todo.text}
)}
+ return ( + <> + {errors?.hasErrors &&
Todos may be out of date.
} + {data.map((todo) => ( +
{todo.text}
+ ))} + + ) } const App = () => ( @@ -241,7 +369,10 @@ const App = () => ( ) ``` -With this approach, loading states are handled by `` and error states are handled by `` instead of within your component logic. See the [React Suspense section in Live Queries](./live-queries#using-with-react-suspense) for more details. +`` handles the initial load and `` handles an initial +error. After the first render, the hook does not throw, as in TanStack Query: +problems appear in `errors`, and a crash sets `isError` while the last data +stays on screen. See the [React Suspense section in Live Queries](./live-queries#using-with-react-suspense) for more details. When every eager SQLite persisted source opts in, a failed client query stream can produce a different initial result. React waits for every persisted restore. If each restore succeeds, React renders the query result. A derived-query failure still reaches the Error Boundary. See [Render after SQLite restore](./sqlite-persistence.md#render-after-sqlite-restore) for the option and its limits. @@ -358,7 +489,7 @@ and the transaction remains completed. This is not a persistence failure. ### Invalid Collection State -Collections in an `error` state cannot perform operations and must be manually recovered: +Collections in an `error` state, after an initial error or a crash, cannot perform operations and must be restarted: ```ts import { CollectionInErrorStateError } from "@tanstack/db" @@ -529,9 +660,9 @@ When sync errors occur: - Error is logged to console: `[QueryCollection] Error observing query...` - An initial failure marks the collection as `error` because no usable snapshot exists - Readiness waits such as `preload()` and `toArrayWhenReady()` reject with the cause passed to `markError(error)` while the collection is in that initial error state -- A later refetch failure keeps the collection `ready` and preserves its cached data -- Error tracking counters are updated (`lastError`, `errorCount`) -- A later successful refetch recovers an initial `error` collection to `ready`; a new readiness wait then resolves normally +- A later refetch failure keeps the collection `ready` and preserves its cached data, and reports a health error that is not final, because TanStack Query fetches again +- Error tracking counters are updated (`lastError`, `errorCount`); after the collection is ready, `collection.health` and the `errors` tree mirror `lastError` +- A later successful refetch clears `lastError` and the health error, but it does not move an initial `error` collection back to `ready`; restart it with `cleanup()` and `preload()` ### Sync Write Errors @@ -630,20 +761,27 @@ Cleanup also disposes dependent Effects; recreate them after the source restarts ### Graceful Degradation -Collections continue to work with cached data even when sync fails: +After the first ready, Collections keep working with their rows when sync +fails. The error tree tells you which source has the problem and whether its +sync adapter is still trying: ```tsx const TodoApp = () => { - const { data, isError } = useLiveQuery((query) => + const { data, errors } = useLiveQuery((query) => query.from({ todos: todoCollection }) ) + const todosError = errors?.sources[0]?.error return (
- {isError && ( -
Sync failed, but you can still view cached data
+ {todosError && ( +
+ {todosError.final + ? "Sync stopped. Reload to try again." + : "Reconnecting. Showing saved data."} +
)} - {data?.map(todo => )} + {data.map((todo) => )}
) } @@ -814,7 +952,7 @@ Thrown when calling `commit()` on a sync transaction that's already committed. 2. **Import specific error types** - Import only the error classes you need for better tree-shaking 3. **Always handle SchemaValidationError** - Provide clear feedback for validation failures -4. **Check collection status** - Use `isError`, `isLoading`, `isReady` flags in React components +4. **Check both axes** - Use `isLoading` and `isError` for whether data can be shown, and `errors.hasErrors` for whether it may be stale 5. **Handle transaction promises** - Always handle `when('settled')` rejections ## Example: Complete Error Handling @@ -864,7 +1002,7 @@ const todosQuery = createLiveQueryCollection((query) => ) const TodoApp = () => { - const { data, isError, isLoading } = useLiveQuery(todosQuery) + const { data, isError, isLoading, errors } = useLiveQuery(todosQuery) const handleAddTodo = async (text: string) => { try { @@ -900,7 +1038,7 @@ const TodoApp = () => { if (isError) { return (
-
Collection error - data may be stale
+
Todos could not load
) @@ -912,10 +1050,11 @@ const TodoApp = () => { return (
+ {errors?.hasErrors &&
Todos may be out of date
} - {data?.map(todo => ( + {data.map((todo) => (
{todo.text}
))}
diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 8b1ea145c8..013ed14e0c 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -424,6 +424,43 @@ function UserList() { } ``` +### Stale Data and the Error Tree + +Every live query hook also returns `errors`, an error tree with one node for +the query and one for each source alias. After the query is ready, a source +that loses its sync connection keeps its rows, and `status` stays `ready`. The +problem appears only in `errors`: + +```tsx +function UserList() { + const { data, isLoading, isError, errors } = useLiveQuery((q) => + q.from({ user: usersCollection }), + ) + + if (isLoading) return
Loading...
+ if (isError) return
Error loading users
+ + return ( + <> + {errors?.hasErrors &&
Users may be out of date
} +
    + {data.map((user) => ( +
  • {user.name}
  • + ))} +
+ + ) +} +``` + +> **Important:** `isError` means the query must restart: its initial load +> failed, or a source crashed. To show a "data may be out of date" banner, +> check `errors.hasErrors`. `useLiveSuspenseQuery` also returns `errors`, and +> after its first render it never throws for these problems. + +See [Collection Status and Error States](./error-handling.md#collection-status-and-error-states) +for the shape of the tree, its phases, and how to restart. + ### Conditional Queries For optional inputs, prefer rendering the query component only after the inputs exist. That avoids creating a live query before all required values exist. @@ -471,7 +508,7 @@ const { data, isEnabled, status } = useLiveQuery({ The top-level callback form supports the same behavior. When the query is disabled: - `status` is `'disabled'` -- `data`, `state`, and `collection` are `undefined` +- `data`, `state`, `collection`, and `errors` are `undefined` - `isEnabled` is `false` - `isReady` is `true` - `isLoading`, `isIdle`, `isError`, and `isCleanedUp` are all `false` diff --git a/docs/guides/sqlite-persistence.md b/docs/guides/sqlite-persistence.md index ca0ccf2cc5..cb9ed3d929 100644 --- a/docs/guides/sqlite-persistence.md +++ b/docs/guides/sqlite-persistence.md @@ -356,7 +356,9 @@ The Node package prunes its applied transaction log by default. Its [README](htt ## Errors and limits -An unavailable browser storage capability raises `PersistenceUnavailableError`. A durable write failure raises `PersistedCollectionDurabilityError` and puts the Collection in its error state. +An unavailable browser storage capability raises `PersistenceUnavailableError`. A durable write failure raises `PersistedCollectionDurabilityError` and crashes the Collection: it moves to its error state even after it was ready, and every live query and Effect over it fails too. Restart it with `cleanup()` and `preload()`. + +Failures from the wrapped sync adapter keep their usual meaning. Before the Collection is ready, the adapter's `markError` fails the sync run. After it, the adapter's `reportError` and `reportHealthy` appear in `collection.health` and in each live query's `errors` tree, and the persisted rows stay. In a follower tab, a failed request routed to the leader appears as a request error on the query that asked for the data. If a coordinated write loses its response, the result can be uncertain. `IndeterminateCommitError` means the application must check the durable state before it retries that write. From 60cd0b699c7ea8d55b87917b5ebb44822bd00d55 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 14:20:26 -0700 Subject: [PATCH 042/106] docs: add the health changeset, coverage owner, and plan status The changeset is a minor release for each changed package, with a migration guide for the breaking changes. The coverage map names the health oracle, its receiving adapter and framework witnesses, and its open limits. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 25 +++++++++++++++++++ .../designs/source-failure-and-health/PLAN.md | 9 ++++--- docs/contributing/oracle-coverage.md | 1 + 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .changeset/source-health-channel.md diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md new file mode 100644 index 0000000000..35cacd7d89 --- /dev/null +++ b/.changeset/source-health-channel.md @@ -0,0 +1,25 @@ +--- +'@tanstack/db': minor +'@tanstack/db-sqlite-persistence-core': minor +'@tanstack/electric-db-collection': minor +'@tanstack/powersync-db-collection': minor +'@tanstack/query-db-collection': minor +'@tanstack/trailbase-db-collection': minor +'@tanstack/react-db': minor +'@tanstack/vue-db': minor +'@tanstack/solid-db': minor +'@tanstack/svelte-db': minor +'@tanstack/angular-db': minor +--- + +Collections now separate two questions: whether data can be shown, and whether it is still kept up to date. + +`status` and `isError` describe the initial load of a sync run. A failure after a Collection is ready no longer moves it to `error`: its rows stay, and the problem appears in the new `collection.health`, the `health:change` event, and the `errors` tree that every live-query hook returns. `errors` has one node per query alias with each node's status and error details, keeps its object identity while unchanged, and is plain data apart from each error's `cause`. Effects receive the same tree through `onHealthChange`. + +Breaking changes and migration: + +- An initial error is final for its sync run. A later successful sync no longer moves a Collection from `error` back to `ready`. Restart instead: `await collection.cleanup()` then `await collection.preload()`. For query-db, a successful refetch or `utils.clearError()` after an initial failure clears `utils.lastError` but leaves the status `error` until a restart. +- After a Collection is ready, sync failures no longer set `status` to `error` or `isError` to `true`. To show a "data may be out of date" message, read `errors.hasErrors` from the hook, or `collection.health`. +- A Collection that can no longer serve reads or accept writes crashes: a closed IndexedDB connection or a failed local SQLite persistence write moves it to `error` even after it was ready. Every live query and Effect over a Collection in `error` now fails too, whether the cause is an initial error or a crash. +- Adapter authors: call `markError(error)` only when you give up before the first `markReady()`. After it, call `reportError(error, { final })` for problems and `reportHealthy()` when sync works again. `markReady()` after `markError()` in the same sync run now throws. `markError()` after the first `markReady()` is treated as `reportError(error, { final: true })` and logs a development warning. +- Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db mirrors `utils.lastError` into Collection health after readiness. diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 55249b3f59..e94431ca5d 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -1,8 +1,11 @@ # Plan: final initial errors and the source health channel -Status: planned, not started. Do this after #2126, #2127, and #2128 merge. -It ships as one new pull request, because it is a breaking change and a -significant product change. It replaces #2129. +Status: implemented on `feat/source-health-channel`; the pull request is next. +It ships as one pull request, because it is a breaking change and a +significant product change. It replaces #2129. Later decisions (2026-10-14): +a crashed Collection ends every live query and Effect above it; terminal local +persistence failures crash; `request` stays the public phase name; a live +query's Collection health includes its request errors. Branch: `feat/source-health-channel` (worktree `.worktrees/source-health-channel`). diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3b1c899007..8bc6545238 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,6 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | +| Collection health and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the initial gate, health problems with serials, and crashes derives each live query's gate and its expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `reportFinal`, `healthy`, `crash`, and restart; the check compares status, rows, the projected tree, node identity, and `status:change`/`health:change` ordering after every step. Request and Effect lanes cover request errors and Effect disposal on an initial error or crash. The shared framework conformance scenario `health-after-readiness` judges the same law through every hook. Adapter receivers: Electric's process grammar and partition laws, TrailBase's stream-end lane, PowerSync's rebuild outage, query-db's `utils.lastError` mirror, and the persisted durability lanes. Open: on-demand sources that fail their own initial sync after a live query is ready are generated only in the Effect lane; a Suspense render after readiness has no direct framework witness; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The virtual-clock owner checks queue order, bounded admission, cleanup draining in FIFO/LIFO order at the configured wait, admitted writes after separate Collection cleanup, post-cleanup queue rejection with a disposal reason, pending debounce drain at the last call's quiet edge, pending trailing throttle drain at its regular edge, debounce and throttle edge options including omitted options, immediate rejection of dropped leading-only calls and both-disabled calls, caller-option immutability, and settlement. New debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | From c2e3b77a892fa591ad1a3a9bb957062bbd9c3d31 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 14:28:54 -0700 Subject: [PATCH 043/106] docs(skills): teach Collection health and the error tree The custom-adapter skill drops the claim that a later markReady() recovers an error and teaches markError before readiness, reportError and reportHealthy after it, and restart. The framework skills list the errors tree and separate isError, which needs a restart, from errors.hasErrors, which means stale data. Co-authored-by: Isaac --- .../angular-db/skills/angular-db/SKILL.md | 3 +- .../db/skills/db-core/custom-adapter/SKILL.md | 28 ++++++++++++------- packages/react-db/skills/react-db/SKILL.md | 3 ++ packages/solid-db/skills/solid-db/SKILL.md | 3 +- packages/svelte-db/skills/svelte-db/SKILL.md | 1 + packages/vue-db/skills/vue-db/SKILL.md | 3 +- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/angular-db/skills/angular-db/SKILL.md b/packages/angular-db/skills/angular-db/SKILL.md index 11f6ac3e4f..50505d35bb 100644 --- a/packages/angular-db/skills/angular-db/SKILL.md +++ b/packages/angular-db/skills/angular-db/SKILL.md @@ -66,7 +66,8 @@ Returns an object with Angular `Signal` properties — call with `()` in temp const query = injectLiveQuery((q) => q.from({ todo: todoCollection })) // query.data() → Array // query.status() → CollectionStatus | 'disabled' -// query.isLoading(), query.isReady(), query.isError() +// query.isLoading(), query.isReady(), query.isError() (query must restart) +// query.errors() → error tree; errors()?.hasErrors means data may be stale // query.isIdle(), query.isCleanedUp() (seldom used) // query.state() → Map // query.collection() → Collection | null diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index 022485e31b..fff3c60469 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -2,7 +2,8 @@ name: db-core/custom-adapter description: > Building custom collection adapters for new backends. SyncConfig interface: - sync function receiving begin, write, commit, markReady, markError, truncate, metadata + sync function receiving begin, write, commit, markReady, markError, + reportError, reportHealthy, truncate, metadata primitives and returning cleanup, loadSubset, and optional unloadSubset handlers. ChangeMessage format (insert, update, delete). On-demand LoadSubsetOptions @@ -94,8 +95,8 @@ function myBackendCollectionOptions(config: { .catch((error) => { if (initialSyncAbort.signal.aborted) return console.error('Initial sync failed:', error) - // Only initial startup owns collection readiness. A later refetch - // failure must keep the last ready snapshot usable. + // Give up only before readiness. After it, report the problem + // with reportError() and keep the rows. if (collection.status === 'loading') markError(error) }) @@ -346,13 +347,20 @@ sync: ({ begin, write, commit, markReady }) => { `markReady()` transitions the collection to "ready" status. Without it, live queries never resolve and `useLiveSuspenseQuery` hangs forever in Suspense. -If initial sync fails before it produces a usable snapshot, call -`markError(error)` instead. This rejects readiness waits with the supplied cause -and moves dependent live queries to the error state. Calling `markError()` -without a cause remains supported and rejects with a generic collection-state -error. Later successful work in the same sync run can call `markReady()` to -recover. Cleanup ends that run; a restart invokes `sync()` again and starts a -new one. +If initial sync fails before it produces a usable snapshot and you stop +retrying, call `markError(error)` instead. This rejects readiness waits with the +supplied cause and moves dependent live queries to the error state. Calling +`markError()` without a cause remains supported and rejects with a generic +collection-state error. The error is final for the sync run: `markReady()` +afterward throws. Only a restart (`cleanup()`, then `preload()`) starts a new +sync run. + +After the first `markReady()`, never move the Collection to `error`. Report a +sync problem with `reportError(error, { final })`, keeping the rows, and call +`reportHealthy()` when sync works again. Pass `final: true` only when you stop +trying; only a restart clears a final error. Apps read these reports from +`collection.health` and from each live query's `errors` tree. While a retry is +in progress, report nothing. Source: docs/guides/collection-options-creator.md diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 562d9b3d36..5e6ac457c6 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -72,6 +72,7 @@ const { isError, isIdle, isCleanedUp, + errors, // error tree: errors.hasErrors means data may be stale } = useLiveQuery({ query: (q) => q @@ -100,6 +101,8 @@ const { data, status } = useLiveQuery({ // When disabled: status='disabled', data=undefined ``` +`isError` means the query must restart (its initial load failed or a source crashed). After the query is ready, sync problems never set it: read `errors?.hasErrors` from the error tree to show stale-data warnings. + ### useLiveSuspenseQuery ```tsx diff --git a/packages/solid-db/skills/solid-db/SKILL.md b/packages/solid-db/skills/solid-db/SKILL.md index 0039c6cc89..38b3cd64f2 100644 --- a/packages/solid-db/skills/solid-db/SKILL.md +++ b/packages/solid-db/skills/solid-db/SKILL.md @@ -61,7 +61,8 @@ const query = useLiveQuery((q) => q.from({ todo: todoCollection })) // query() → Array (data) — or T | undefined when using findOne() // query.data → DEPRECATED — use query() instead. Migrate any existing code. // query.status → CollectionStatus -// query.isLoading, query.isReady, query.isError +// query.isLoading, query.isReady, query.isError (query must restart) +// query.errors → error tree; query.errors?.hasErrors means data may be stale // query.state → ReactiveMap // query.collection → Collection diff --git a/packages/svelte-db/skills/svelte-db/SKILL.md b/packages/svelte-db/skills/svelte-db/SKILL.md index 295103a302..bbda280f85 100644 --- a/packages/svelte-db/skills/svelte-db/SKILL.md +++ b/packages/svelte-db/skills/svelte-db/SKILL.md @@ -59,6 +59,7 @@ Returns a reactive object with getter properties: // Query function — access via dot notation const query = useLiveQuery((q) => q.from({ todo: todoCollection })) // query.data, query.isLoading, query.status, etc. +// query.errors → error tree; query.errors?.hasErrors means data may be stale // With reactive deps — use GETTER FUNCTIONS let minPriority = $state(5) diff --git a/packages/vue-db/skills/vue-db/SKILL.md b/packages/vue-db/skills/vue-db/SKILL.md index 3779be9a5d..09f552c407 100644 --- a/packages/vue-db/skills/vue-db/SKILL.md +++ b/packages/vue-db/skills/vue-db/SKILL.md @@ -3,7 +3,8 @@ name: vue-db description: > Vue 3 bindings for TanStack DB. useLiveQuery composable with MaybeRefOrGetter query functions, ComputedRef return values for all fields - (data, state, collection, status, isLoading, isReady, isError). + (data, state, collection, status, isLoading, isReady, isError, errors). + isError means the query must restart; errors.hasErrors means stale data. Dependency arrays with Vue refs. Conditional queries via returning undefined/null. Pre-created collection support via ref or getter. Import from @tanstack/vue-db (re-exports all of @tanstack/db). From 12948ff9f58f2acb1860f955e75f8130140eb324 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 14:32:48 -0700 Subject: [PATCH 044/106] docs(db): restore the release-pass comment in Effect disposal The glossary pass renamed a loop pass to a sync run. Co-authored-by: Isaac --- packages/db/src/query/effect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 7458d401ab..9056b93701 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -1207,7 +1207,7 @@ class EffectPipelineRunner { this.disposed = true this.subscribedToAllCollections = false - // Release every source in one sync run; the first failure wins after the + // Release every source in one pass; the first failure wins after the // peers finish. A reentrant dispose returns at the guard above, so this // call still owns each release exactly once. try { From e815ab5a0b67d5dfd8babf15c3616f0928b235cb Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 20:37:26 -0700 Subject: [PATCH 045/106] refactor(frameworks): share the live-query result flags across overloads React's and Solid's useLiveQuery overloads, and Vue's and Angular's result interfaces, each repeated the same status, persistence, and health fields. Declare them once per package and intersect or extend them. Co-authored-by: Isaac --- packages/angular-db/src/index.ts | 52 ++++------ packages/react-db/src/useLiveQuery.ts | 134 +++++--------------------- packages/solid-db/src/useLiveQuery.ts | 68 ++++--------- packages/vue-db/src/useLiveQuery.ts | 38 +++----- 4 files changed, 73 insertions(+), 219 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 19c32c5630..7c456df633 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -38,17 +38,8 @@ export * from '@tanstack/db' * The result of calling `injectLiveQuery`. * Contains reactive signals for the query state and data. */ -export interface InjectLiveQueryResult { - /** A signal containing the complete state map of results keyed by their ID */ - state: Signal>> - /** A signal containing the results as an array, or single result for findOne queries */ - data: Signal> - /** A signal containing the underlying collection instance (null for disabled queries) */ - collection: Signal, - string | number, - {} - > | null> +/** Status, persistence, and health signals every `injectLiveQuery` result has. */ +export interface InjectLiveQueryFlags { /** A signal containing the current status of the collection */ status: Signal /** A signal indicating whether the collection is currently loading */ @@ -68,6 +59,21 @@ export interface InjectLiveQueryResult { errors: Signal } +export interface InjectLiveQueryResult< + TContext extends Context, +> extends InjectLiveQueryFlags { + /** A signal containing the complete state map of results keyed by their ID */ + state: Signal>> + /** A signal containing the results as an array, or single result for findOne queries */ + data: Signal> + /** A signal containing the underlying collection instance (null for disabled queries) */ + collection: Signal, + string | number, + {} + > | null> +} + type InferConditionalResultType = TContext extends SingleResult ? InferResultType | [] @@ -84,40 +90,20 @@ export interface InjectLiveQueryResultWithCollection< TResult extends object = any, TKey extends string | number = string | number, TUtils extends Record = {}, -> { +> extends InjectLiveQueryFlags { state: Signal> data: Signal> collection: Signal | null> - status: Signal - isLoading: Signal - isReady: Signal - persistedStatus: Signal - isPersistedReady: Signal - persistedError: Signal - isIdle: Signal - isError: Signal - isCleanedUp: Signal - errors: Signal } export interface InjectLiveQueryResultWithSingleResultCollection< TResult extends object = any, TKey extends string | number = string | number, TUtils extends Record = {}, -> { +> extends InjectLiveQueryFlags { state: Signal> data: Signal collection: Signal<(Collection & SingleResult) | null> - status: Signal - isLoading: Signal - isReady: Signal - persistedStatus: Signal - isPersistedReady: Signal - persistedError: Signal - isIdle: Signal - isError: Signal - isCleanedUp: Signal - errors: Signal } export function injectLiveQuery< diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index f5a4ca24e2..9dbe11b4d7 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -384,15 +384,8 @@ export function warnUnhashableDerivedIdentity( * * ) */ -// Overload 1: Accept query function that always returns QueryBuilder -export function useLiveQuery( - queryFn: (q: InitialQueryBuilder) => QueryBuilder, - deps?: Array, -): { - state: Map> - data: InferResultType - collection: Collection, string | number, {}> - status: CollectionStatus // Can't be disabled if always returns QueryBuilder +/** Status, persistence, and health fields every `useLiveQuery` result has. */ +type UseLiveQueryFlags = { isLoading: boolean isReady: boolean persistedStatus: LiveQueryPersistedStatus @@ -401,10 +394,21 @@ export function useLiveQuery( isIdle: boolean isError: boolean isCleanedUp: boolean - isEnabled: true // Always true if always returns QueryBuilder errors: ErrorTreeNode | undefined } +// Overload 1: Accept query function that always returns QueryBuilder +export function useLiveQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + deps?: Array, +): { + state: Map> + data: InferResultType + collection: Collection, string | number, {}> + status: CollectionStatus // Can't be disabled if always returns QueryBuilder + isEnabled: true // Always true if always returns QueryBuilder +} & UseLiveQueryFlags + // Overload 2: Accept query function that can return undefined/null export function useLiveQuery( queryFn: ( @@ -416,17 +420,8 @@ export function useLiveQuery( data: InferResultType | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 3: Accept query function that can return LiveQueryCollectionConfig export function useLiveQuery( @@ -439,17 +434,8 @@ export function useLiveQuery( data: InferResultType | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 4: Accept query function that can return Collection export function useLiveQuery< @@ -466,17 +452,8 @@ export function useLiveQuery< data: Array | undefined collection: Collection | undefined status: UseLiveQueryStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 5: Accept query function that can return all types export function useLiveQuery< @@ -503,17 +480,8 @@ export function useLiveQuery< | Collection | undefined status: UseLiveQueryStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags /** * Create a live query using configuration object @@ -558,17 +526,8 @@ export function useLiveQuery( data: InferResultType collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled when query always returns a builder - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: true // Always true when query always returns a builder - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 7: Accept config object with a query that can return undefined/null export function useLiveQuery( @@ -578,17 +537,8 @@ export function useLiveQuery( data: InferResultType | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 8: Accept config object with legacy deps export function useLiveQuery( @@ -599,17 +549,8 @@ export function useLiveQuery( data: InferResultType collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled when query always returns a builder - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: true // Always true when query always returns a builder - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 9: Accept config object with legacy deps and a query that can return undefined/null export function useLiveQuery( @@ -620,17 +561,8 @@ export function useLiveQuery( data: InferResultType | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags /** * Subscribe to an existing live query collection @@ -673,17 +605,8 @@ export function useLiveQuery< data: Array collection: Collection status: CollectionStatus // Can't be disabled for pre-created live query collections - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: true // Always true for pre-created live query collections - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 10: Accept pre-created live query collection with singleResult: true export function useLiveQuery< @@ -697,17 +620,8 @@ export function useLiveQuery< data: TResult | undefined collection: Collection & SingleResult status: CollectionStatus // Can't be disabled for pre-created live query collections - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: true // Always true for pre-created live query collections - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 1271f949c8..6d2f7874e8 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -109,6 +109,19 @@ type InferConditionalResultType = *
* ) */ +/** Status, persistence, and health fields every `useLiveQuery` result has. */ +type UseLiveQueryFlags = { + isLoading: boolean + isReady: boolean + persistedStatus: LiveQueryPersistedStatus + isPersistedReady: boolean + persistedError: unknown | undefined + isIdle: boolean + isError: boolean + isCleanedUp: boolean + errors: ErrorTreeNode | undefined +} + // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, @@ -121,16 +134,7 @@ export function useLiveQuery( state: ReactiveMap> collection: Collection, string | number, {}> status: CollectionStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 1b: Accept query function that can return undefined/null export function useLiveQuery( @@ -146,16 +150,7 @@ export function useLiveQuery( state: ReactiveMap> collection: Collection, string | number, {}> | null status: CollectionStatus | `disabled` - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags /** * Create a live query using configuration object @@ -209,16 +204,7 @@ export function useLiveQuery( state: ReactiveMap> collection: Collection, string | number, {}> status: CollectionStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags /** * Subscribe to an existing live query collection @@ -273,16 +259,7 @@ export function useLiveQuery< state: ReactiveMap collection: Collection status: CollectionStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Overload 3b: Accept pre-created live query collection with singleResult: true export function useLiveQuery< @@ -302,16 +279,7 @@ export function useLiveQuery< state: ReactiveMap collection: Collection & SingleResult status: CollectionStatus - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean - errors: ErrorTreeNode | undefined -} +} & UseLiveQueryFlags // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 9cbf6de2ad..f0ec4a7779 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -53,10 +53,8 @@ function snapshotRows(data: unknown): Array { * @property isError - True when query encountered an error * @property isCleanedUp - True when query has been cleaned up */ -export interface UseLiveQueryReturn { - state: ComputedRef>> - data: ComputedRef> - collection: ComputedRef, string | number, {}>> +/** Status, persistence, and health refs every `useLiveQuery` result has. */ +export interface UseLiveQueryFlags { status: ComputedRef isLoading: ComputedRef isReady: ComputedRef @@ -69,6 +67,14 @@ export interface UseLiveQueryReturn { errors: ComputedRef } +export interface UseLiveQueryReturn< + TContext extends Context, +> extends UseLiveQueryFlags { + state: ComputedRef>> + data: ComputedRef> + collection: ComputedRef, string | number, {}>> +} + type InferConditionalResultType = TContext extends SingleResult ? InferResultType | [] @@ -91,40 +97,20 @@ export interface UseLiveQueryReturnWithCollection< T extends object, TKey extends string | number, TUtils extends Record, -> { +> extends UseLiveQueryFlags { state: ComputedRef> data: ComputedRef> collection: ComputedRef> - status: ComputedRef - isLoading: ComputedRef - isReady: ComputedRef - persistedStatus: ComputedRef - isPersistedReady: ComputedRef - persistedError: ComputedRef - isIdle: ComputedRef - isError: ComputedRef - isCleanedUp: ComputedRef - errors: ComputedRef } export interface UseLiveQueryReturnWithSingleResultCollection< T extends object, TKey extends string | number, TUtils extends Record, -> { +> extends UseLiveQueryFlags { state: ComputedRef> data: ComputedRef collection: ComputedRef & SingleResult> - status: ComputedRef - isLoading: ComputedRef - isReady: ComputedRef - persistedStatus: ComputedRef - isPersistedReady: ComputedRef - persistedError: ComputedRef - isIdle: ComputedRef - isError: ComputedRef - isCleanedUp: ComputedRef - errors: ComputedRef } /** From afbb57d565f772fdd09a4b9f661193506695660d Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 20:43:11 -0700 Subject: [PATCH 046/106] refactor(db): share request-error tracking and derive the sync error Live queries and Effects each kept a set of failed request keys beside a request problem. One RequestErrors tracker now serves both; the live query's lifecycle owns its tracker and resets it at cleanup, so the builder no longer clears its own copy. getSyncError() reads the initial problem's cause instead of a second field holding the same value. Co-authored-by: Isaac --- packages/db/src/collection/lifecycle.ts | 41 ++++++++++++------- packages/db/src/query/effect.ts | 16 +++----- .../query/live/collection-config-builder.ts | 11 +---- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index a29ea5c0d2..886259269f 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -56,7 +56,6 @@ export class CollectionLifecycleManager< private readyFailureSink: ((failure: { error: unknown }) => void) | undefined public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null - private syncError: unknown // Health is metadata after Collection readiness. The sync adapter's own report and a // live query's request failures are separate, so settling a request cannot // clear the sync adapter's problem. @@ -64,7 +63,7 @@ export class CollectionLifecycleManager< // Error tree detail for the initial gate error, kept so readers see the // same object until the sync run ends. private initialProblem: CollectionErrorInfo | null = null - private requestProblem: CollectionErrorInfo | null = null + private requestErrors = new RequestErrors() private cleanupConfig: () => void private statusRevision = 0 private cleaningUp = false @@ -278,13 +277,12 @@ export class CollectionLifecycleManager< // The first error is final for the sync run. if (this.status === `error`) return this.validateStatusTransition(this.status, `error`) - this.syncError = error this.initialProblem = describeProblem(error, `initial`, true) this.setStatus(`error`) } public get health(): CollectionHealth { - const error = this.sourceProblem ?? this.requestProblem + const error = this.sourceProblem ?? this.requestErrors.problem return { isError: error !== null, error } } @@ -310,17 +308,15 @@ export class CollectionLifecycleManager< this.events.emitHealthChange() } - /** @internal A live query's load failed after Collection readiness. */ - public reportRequestError(error: unknown): void { - this.requestProblem = describeProblem(error, `request`, false) + /** @internal A live query's request failed after initial-query readiness. */ + public reportRequestError(key: string, error: unknown): void { + this.requestErrors.fail(key, error) this.events.emitHealthChange() } /** @internal The failed request settled or was released. */ - public clearRequestError(): void { - if (!this.requestProblem) return - this.requestProblem = null - this.events.emitHealthChange() + public clearRequestError(key: string): void { + if (this.requestErrors.settle(key)) this.events.emitHealthChange() } private assertReportable(operation: string): void { @@ -335,7 +331,7 @@ export class CollectionLifecycleManager< /** Return the cause supplied by the current sync run, if any. */ public getSyncError(): unknown { - return this.syncError + return this.initialProblem?.cause } public assertCanStartSync(): void { @@ -570,10 +566,9 @@ export class CollectionLifecycleManager< CleanupQueue.getInstance().cancel(this) this.hasBeenReady = false - this.syncError = undefined // Health belongs to the discarded attempt. this.sourceProblem = null - this.requestProblem = null + this.requestErrors = new RequestErrors() this.initialProblem = null // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. @@ -617,6 +612,24 @@ export class CollectionLifecycleManager< } } +/** @internal Requests that failed after initial-query readiness, by issuer. */ +export class RequestErrors { + private readonly failed = new Set() + problem: CollectionErrorInfo | null = null + + fail(key: string, error: unknown): void { + this.failed.add(key) + this.problem = describeProblem(error, `request`, false) + } + + /** Returns whether the last failed request settled. */ + settle(key: string): boolean { + if (!this.failed.delete(key) || this.failed.size > 0) return false + this.problem = null + return true + } +} + /** @internal Error tree detail for a problem value. */ export function describeProblem( error: unknown, diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 9056b93701..5008ae0e54 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -6,7 +6,7 @@ import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' -import { describeProblem } from '../collection/lifecycle.js' +import { RequestErrors } from '../collection/lifecycle.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -41,7 +41,6 @@ import type { } from './compiler/joins.js' import type { ChangeMessage, - CollectionErrorInfo, ErrorTreeNode, KeyedStream, StringCollationConfig, @@ -475,8 +474,7 @@ class EffectPipelineRunner { // initial load has settled, with or without skipInitial. After it, // a Collection health problem does not dispose the Effect. private reachedReady = false - private readonly failedRequests = new Set() - private requestProblem: CollectionErrorInfo | null = null + private readonly requests = new RequestErrors() private readonly onHealthChange: ((errors: ErrorTreeNode) => void) | undefined // Scheduler integration @@ -1080,16 +1078,12 @@ class EffectPipelineRunner { // later load of that source settles. private failLoad(sourceId: string, error: Error): void { if (!this.reachedReady) return this.onSourceError(error) - this.failedRequests.add(sourceId) - this.requestProblem = describeProblem(error, `request`, false) + this.requests.fail(sourceId, error) this.reportHealth() } private settleLoad(sourceId: string): void { - if (!this.failedRequests.delete(sourceId) || this.failedRequests.size > 0) - return - this.requestProblem = null - this.reportHealth() + if (this.requests.settle(sourceId)) this.reportHealth() } /** The error tree for this effect's sources, in query order. */ @@ -1099,7 +1093,7 @@ class EffectPipelineRunner { collectionId: `effect`, kind: `live-query`, status: this.reachedReady ? `ready` : `loading`, - error: this.requestProblem, + error: this.requests.problem, sources: this.collectionSources.map(({ sourceId, alias, collection }) => buildErrorTree(collection, { alias, id: sourceId }), ), diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index f7df4fb7ca..acc376a2d5 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -116,8 +116,6 @@ export class CollectionConfigBuilder< // Error state tracking private isInErrorState = false private fatalQueryError = false - // Lazy demand that failed after the first ready, as request errors. - private readonly failedRequests = new Set() private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions @@ -595,15 +593,12 @@ export class CollectionConfigBuilder< private reportFailedRequest(key: string, error: Error): boolean { const lifecycle = this.liveQueryCollection?._lifecycle if (lifecycle?.status !== `ready`) return false - this.failedRequests.add(key) - lifecycle.reportRequestError(error) + lifecycle.reportRequestError(key, error) return true } private clearFailedRequest(key: string): void { - if (!this.failedRequests.delete(key)) return - if (this.failedRequests.size === 0) - this.liveQueryCollection?._lifecycle.clearRequestError() + this.liveQueryCollection?._lifecycle.clearRequestError(key) } hasPendingSourceRecovery(): boolean { @@ -781,7 +776,6 @@ export class CollectionConfigBuilder< // Reset error state from any previous sync run so a restarted sync can become ready again. this.isInErrorState = false this.fatalQueryError = false - this.failedRequests.clear() this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config @@ -864,7 +858,6 @@ export class CollectionConfigBuilder< this.settledWindow = this.initialWindow this.isInErrorState = false this.fatalQueryError = false - this.failedRequests.clear() // Reset caches so a fresh graph/pipeline is compiled on next start // This avoids reusing a finalized D2 graph across GC restarts From 8bcbe460d90a69834d1b862b62fe84e2c66a21c8 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 20:46:11 -0700 Subject: [PATCH 047/106] refactor(db): find a live query's tree sources through its builder The error tree read a callback that the builder registry also installed on every Collection. Look the builder up in the registry that already maps live-query Collections to their builders, and drop the extra field. Co-authored-by: Isaac --- packages/db/src/collection/error-tree.ts | 7 +++++-- packages/db/src/collection/index.ts | 4 ---- packages/db/src/query/live/collection-registry.ts | 2 -- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index 14d8363b7d..19e0fe89ec 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -1,3 +1,4 @@ +import { getCollectionBuilder } from '../query/live/collection-registry.js' import type { CollectionErrorInfo, ErrorTreeNode } from '../types' import type { CollectionImpl } from './index.js' @@ -31,7 +32,9 @@ export function buildErrorTree( alias: `root`, }, ): ErrorTreeNode { - const sources = collection._errorTreeSources?.() ?? [] + // A live query's tree shape comes from its query definition. + const builder = getCollectionBuilder(collection) + const sources = builder?.errorTreeSources() ?? [] const lifecycle = collection._lifecycle const error: CollectionErrorInfo | null = lifecycle.gateProblem ?? lifecycle.health.error @@ -41,7 +44,7 @@ export function buildErrorTree( return errorTreeNode(collection, id, { alias, collectionId: collection.id, - kind: kind ?? (collection._errorTreeSources ? `live-query` : `source`), + kind: kind ?? (builder ? `live-query` : `source`), status: collection.status, error, ...(instances ? { instances: countInstances(instances()) } : {}), diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index b102ecf0a5..1cf5c1293d 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -18,7 +18,6 @@ import { CollectionIndexesManager } from './indexes' import { CollectionMutationsManager } from './mutations' import { CollectionEventsManager } from './events.js' import { buildErrorTree } from './error-tree.js' -import type { ErrorTreeSource } from './error-tree.js' import type { SortedMap } from '../SortedMap.js' import type { PublicationDeferral } from './changes' import type { CollectionSubscription } from './subscription' @@ -579,9 +578,6 @@ export class CollectionImpl< this._events.emitHealthChange() } - /** @internal Set by a live query: the sources its error tree lists. */ - public _errorTreeSources: (() => ReadonlyArray) | undefined - /** * Get the number of subscribers that ask for this collection's data. A live * query that has no subscriber or preload of its own does not count. diff --git a/packages/db/src/query/live/collection-registry.ts b/packages/db/src/query/live/collection-registry.ts index c61b8e89de..d17b182f92 100644 --- a/packages/db/src/query/live/collection-registry.ts +++ b/packages/db/src/query/live/collection-registry.ts @@ -32,8 +32,6 @@ export function registerCollectionBuilder( builder: CollectionConfigBuilder, ): void { collectionBuilderRegistry.set(collection, builder) - // The error tree's shape comes from the query, so it is fixed from creation. - collection._errorTreeSources = () => builder.errorTreeSources() } /** From 6e013bd41304b8a7111e5971900cf9c1942fea2e Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 21:33:02 -0700 Subject: [PATCH 048/106] refactor!: make markError the single way a sync adapter gives up After Collection readiness, markError and reportError(error, { final: true }) did the same thing, and Electric and TrailBase repeated core's "ready?" branch at each give-up site. markError now means the sync adapter stopped trying in both phases: an initial error before readiness, a final health error after it, with no development warning. reportError takes only the error and always means the adapter keeps trying. The adapters call markError at every give-up site, the persisted wrapper forwards both health reports through one startup helper, and the health oracle drops its reportFinal step: fail after readiness is the final case. The conformance health source gains markError, and the docs, adapter skill, glossary, and changeset describe the simpler rule. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 2 +- .../designs/source-failure-and-health/PLAN.md | 2 +- docs/contributing/glossary.md | 11 ++--- docs/guides/collection-options-creator.md | 26 +++++------- .../src/persisted.ts | 25 +++--------- .../db/skills/db-core/custom-adapter/SKILL.md | 8 ++-- packages/db/src/collection/lifecycle.ts | 29 ++++++-------- packages/db/src/collection/sync.ts | 4 +- packages/db/src/types.ts | 21 +++++----- packages/db/tests/conformance/contract.ts | 6 ++- .../db/tests/conformance/health-source.ts | 6 ++- packages/db/tests/conformance/suite.ts | 6 +-- .../fixtures/development-only-messages.json | 8 ---- .../db/tests/source-health-oracle.test.ts | 39 +++++++----------- .../electric-db-collection/src/electric.ts | 40 ++++--------------- .../powersync-db-collection/src/powersync.ts | 2 +- packages/query-db-collection/src/query.ts | 4 +- packages/react-db/tests/useLiveQuery.test.tsx | 5 +-- .../trailbase-db-collection/src/trailbase.ts | 27 +++---------- 19 files changed, 95 insertions(+), 176 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 35cacd7d89..181737eb32 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -21,5 +21,5 @@ Breaking changes and migration: - An initial error is final for its sync run. A later successful sync no longer moves a Collection from `error` back to `ready`. Restart instead: `await collection.cleanup()` then `await collection.preload()`. For query-db, a successful refetch or `utils.clearError()` after an initial failure clears `utils.lastError` but leaves the status `error` until a restart. - After a Collection is ready, sync failures no longer set `status` to `error` or `isError` to `true`. To show a "data may be out of date" message, read `errors.hasErrors` from the hook, or `collection.health`. - A Collection that can no longer serve reads or accept writes crashes: a closed IndexedDB connection or a failed local SQLite persistence write moves it to `error` even after it was ready. Every live query and Effect over a Collection in `error` now fails too, whether the cause is an initial error or a crash. -- Adapter authors: call `markError(error)` only when you give up before the first `markReady()`. After it, call `reportError(error, { final })` for problems and `reportHealthy()` when sync works again. `markReady()` after `markError()` in the same sync run now throws. `markError()` after the first `markReady()` is treated as `reportError(error, { final: true })` and logs a development warning. +- Adapter authors: call `markError(error)` when you give up. Before the first `markReady()` it fails the sync run; after it, it is a final health error and the rows stay. While you keep trying after readiness, call `reportError(error)`, then `reportHealthy()` when sync works again. `markReady()` after `markError()` in the same sync run now throws. - Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db mirrors `utils.lastError` into Collection health after readiness. diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index e94431ca5d..f55b719d94 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -5,7 +5,7 @@ It ships as one pull request, because it is a breaking change and a significant product change. It replaces #2129. Later decisions (2026-10-14): a crashed Collection ends every live query and Effect above it; terminal local persistence failures crash; `request` stays the public phase name; a live -query's Collection health includes its request errors. +query's Collection health includes its request errors. `markError` means the sync adapter gave up in both phases, so `reportError` has no `final` option and nothing warns; query-db's error utilities and the infinite query's `error` field give way to Collection health. Branch: `feat/source-health-channel` (worktree `.worktrees/source-health-channel`). diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 0538171337..779965c15b 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -69,9 +69,9 @@ sync after that. Neither one changes the other. | initial gate | The role of Collection status during a sync run's initial sync. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `markError()`. After Collection readiness in a sync run, the status no longer reflects sync failures. | Collection health or a live query's request errors. | | initial error | The `markError()` failure that ends a sync run's initial gate in `error`, recorded with `phase: 'initial'`. It is final for its sync run: `markReady()` afterward throws, and only a restart can reach `ready`. | Health error or a retry. | | crash | Moving a Collection to `error` from any active status, including after Collection readiness, because the Collection can no longer serve reads or admit writes in its sync run. Core uses it for IndexedDB closure, terminal local persistence failures, and internal invariant failures. It is final for the sync run; only a restart recovers. | Initial error, final health error, or cleanup. | -| Collection health | A Collection's report of problems after Collection readiness in the current sync run, read through `collection.health` and `health:change`. A sync adapter changes it only through `reportError()` and `reportHealthy()`. A live-query Collection's health also carries its own request errors. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | -| health error | One problem reported through `reportError()`, recorded as `CollectionErrorInfo` with `phase: 'sync'`. A health error that is not final means the sync adapter is still trying in the current sync run; `reportHealthy()` clears it. | Initial error or request error. | -| final health error | A health error with `final: true`: the sync adapter stopped trying in the current sync run. Only a restart clears it; `reportHealthy()` does not. | A Collection status of `error`. | +| Collection health | A Collection's report of problems after Collection readiness in the current sync run, read through `collection.health` and `health:change`. A sync adapter changes it only through `reportError()`, `reportHealthy()`, and `markError()`. A live-query Collection's health also carries its own request errors. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | +| health error | One problem reported through `reportError()` or, as a final health error, `markError()`, recorded as `CollectionErrorInfo` with `phase: 'sync'`. A health error that is not final means the sync adapter is still trying in the current sync run; `reportHealthy()` clears it. | Initial error or request error. | +| final health error | A health error with `final: true`, from `markError()` after Collection readiness: the sync adapter stopped trying in the current sync run. Only a restart clears it; `reportHealthy()` does not. | A Collection status of `error`. | | request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, recorded with `phase: 'request'` on that query's error tree node and, for a live query, in its live-query Collection's health. The source Collection's health does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Health error or request data. | | error tree | The `errors` value of a live query or Effect: one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged. | Collection health or an error list. | | error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. An include node summarizes all its per-row child Collections in `instances`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | @@ -227,8 +227,9 @@ Use nouns for state and verbs for transitions: `cleaned-up`. Restart then **starts** a new sync run. - Truncate replay **replaces** source state inside the current sync run and any current dependent graph. -- A sync adapter **gives up** with `markError()` before Collection readiness. - After it, the adapter **reports** a health error and **reports healthy**. +- A sync adapter **gives up** with `markError()`: an initial error before + Collection readiness, a final health error after it. While it keeps trying, + it **reports** a health error and **reports healthy**. A restart **clears** a final health error. - A Collection **crashes** when it can no longer serve reads or admit writes. diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index 6e3766ca26..46965386c9 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -174,9 +174,9 @@ The sync process follows this lifecycle: 2. **write()** - Add changes to the pending transaction (buffered until commit) 3. **commit()** - Apply all changes atomically to the collection state 4. **markReady()** - Signal that a usable initial snapshot exists -5. **markError(error?)** - Signal that you gave up on the initial sync before producing a usable snapshot; pass the cause so readiness waits reject with it. It is final for the sync run: `markReady()` afterward throws, and only a restart starts over -6. **reportError(error, { final })** - After the Collection is ready, report a sync problem without changing its status or rows. Pass `final: true` when you stop trying; only a restart clears a final error -7. **reportHealthy()** - After the Collection is ready, report that sync works again. It clears an error that is not final +5. **markError(error?)** - Signal that you stopped trying. Before the Collection is ready, this fails the sync run: readiness waits reject with the cause, and `markReady()` afterward throws. After it, this is a final health error: the status and rows stay. Only a restart starts over +6. **reportError(error)** - After the Collection is ready, report a sync problem while you keep trying, without changing its status or rows +7. **reportHealthy()** - After the Collection is ready, report that sync works again. It clears the problem from `reportError()` ### Reporting failures @@ -185,13 +185,11 @@ still kept up to date. Follow these rules so both stay truthful: 1. While you retry the initial sync, call nothing. The Collection stays `loading`. -2. Call `markError(error)` only when you give up before the first +2. Call `markError(error)` when you give up, before or after the first `markReady()`. Do not call `markReady()` after it. -3. After the first `markReady()`, call `reportError(error, { final })` for - problems and `reportHealthy()` when sync works again. Never discard rows - because sync failed. -4. Stay silent while a retry is in progress, and report `final: true` only - when you stop for good. +3. After the first `markReady()`, call `reportError(error)` for a problem + while you keep trying, and `reportHealthy()` when sync works again. Never + discard rows because sync failed. ```ts sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy }) => { @@ -209,20 +207,14 @@ sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy } }, onError: (error, { willRetry }) => { - if (!ready) { - if (!willRetry) markError(error) - } else { - reportError(error, { final: !willRetry }) - } + if (!willRetry) markError(error) + else if (ready) reportError(error) }, }) return () => stream.close() } ``` -Calling `markError(error)` after the first `markReady()` still works while you -migrate: it is treated as `reportError(error, { final: true })` and logs a -development warning. `commit()` accepts the transaction and returns `true`. An accepted transaction always applies, in commit order. While an optimistic transaction is persisting, diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 1ade14d145..a07863607b 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -5890,34 +5890,21 @@ function createWrappedSyncConfig< } let sourceSession = 0 - const reportUpstreamHealthy = () => { + // Upstream health waits for persisted startup, as markReady does. + const afterStartup = (report: () => void) => { if (startupState.cleanedUp || getTerminalFailure()) return void (fullStartPromise ?? runtime.ensureStarted()) .then(() => { - if (!isCleanedUp() && !getTerminalFailure()) { - params.reportHealthy() - } - }) - .catch(() => undefined) - } - const reportUpstreamErrorForHealth = ( - error: unknown, - options?: { final?: boolean }, - ) => { - if (startupState.cleanedUp || getTerminalFailure()) return - void (fullStartPromise ?? runtime.ensureStarted()) - .then(() => { - if (!isCleanedUp() && !getTerminalFailure()) { - params.reportError(error, options) - } + if (!isCleanedUp() && !getTerminalFailure()) report() }) .catch(() => undefined) } const wrappedParams = { ...params, markError: reportUpstreamError, - reportError: reportUpstreamErrorForHealth, - reportHealthy: reportUpstreamHealthy, + reportError: (error: unknown) => + afterStartup(() => params.reportError(error)), + reportHealthy: () => afterStartup(params.reportHealthy), markReady: () => { if (startupState.cleanedUp || getTerminalFailure()) return void (fullStartPromise ?? runtime.ensureStarted()) diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index fff3c60469..4dabaf1bb6 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -355,10 +355,10 @@ collection-state error. The error is final for the sync run: `markReady()` afterward throws. Only a restart (`cleanup()`, then `preload()`) starts a new sync run. -After the first `markReady()`, never move the Collection to `error`. Report a -sync problem with `reportError(error, { final })`, keeping the rows, and call -`reportHealthy()` when sync works again. Pass `final: true` only when you stop -trying; only a restart clears a final error. Apps read these reports from +After the first `markReady()`, the rows stay. Report a sync problem with +`reportError(error)` while you keep trying, and call `reportHealthy()` when +sync works again. When you stop trying, call `markError(error)`: after +readiness it is a final health error, and only a restart clears it. Apps read these reports from `collection.health` and from each live query's `errors` tree. While a retry is in progress, report nothing. diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 886259269f..9bc4c74b5a 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -250,22 +250,13 @@ export class CollectionLifecycleManager< } /** - * The sync adapter gave up. Before Collection readiness this fails the initial sync - * of the sync run; after it, the problem is final health metadata and the - * data stays. + * The sync adapter gave up. Before Collection readiness this fails the + * initial sync of the sync run; after it, it is a final health error and + * the rows stay. */ public markError(error?: unknown): void { - if (this.status === `ready`) { - if (devBuild() && process.env.NODE_ENV !== `production`) { - console.warn( - `[TanStack DB] Collection "${this.id}" called markError() after Collection readiness. ` + - `Call reportError(error, { final: true }) instead; the status stays ready.`, - ) - } - this.reportError(error, { final: true }) - return - } - this.crash(error) + if (this.status === `ready`) this.recordSourceProblem(error, true) + else this.crash(error) } /** @@ -291,12 +282,16 @@ export class CollectionLifecycleManager< return this.status === `error` ? this.initialProblem : null } - /** The sync adapter's report after Collection readiness. Status and rows do not change. */ - public reportError(error: unknown, options?: { final?: boolean }): void { + /** The sync adapter still tries after a problem. Status and rows do not change. */ + public reportError(error: unknown): void { this.assertReportable(`reportError`) + this.recordSourceProblem(error, false) + } + + private recordSourceProblem(error: unknown, final: boolean): void { // A final problem stays until a restart. if (this.sourceProblem?.final) return - this.sourceProblem = describeProblem(error, `sync`, options?.final === true) + this.sourceProblem = describeProblem(error, `sync`, final) this.events.emitHealthChange() } diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index ee6dcc2632..321310454b 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -323,8 +323,8 @@ export class CollectionSyncManager< markError: (error?: unknown) => { if (isCurrentSync()) this.lifecycle.markError(error) }, - reportError: (error: unknown, options?: { final?: boolean }) => { - if (isCurrentSync()) this.lifecycle.reportError(error, options) + reportError: (error: unknown) => { + if (isCurrentSync()) this.lifecycle.reportError(error) }, reportHealthy: () => { if (isCurrentSync()) this.lifecycle.reportHealthy() diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 0577a11a53..9365ffb659 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -451,21 +451,20 @@ export interface SyncConfig< /** Signal that a usable initial snapshot is available for this attempt. */ markReady: () => void /** - * Signal that the initial sync of this attempt failed for good, before it - * produced a usable snapshot. Call it only when the source has stopped - * trying; while it retries, call nothing. When supplied, `error` is - * preserved as the rejection reason from `preload()`. Calling `markReady()` - * after `markError()` in the same attempt throws. After the first ready, - * `markError(error)` is treated as `reportError(error, { final: true })`. + * Signal that the sync adapter stopped trying. Before Collection readiness + * this is the initial error of the sync run: `preload()` rejects with + * `error`, and `markReady()` afterward throws. After it, this is a final + * health error: the status and rows stay, and only a restart clears it. + * While the sync adapter retries, call nothing. */ markError: (error?: unknown) => void /** - * Report a problem after the first ready. The status and rows do not - * change. `final: true` means the source stopped trying, and only a - * restart clears the problem. Throws before the first ready. + * Report a problem after Collection readiness while the sync adapter + * keeps trying. The status and rows do not change. Throws before + * Collection readiness. */ - reportError: (error: unknown, options?: { final?: boolean }) => void - /** Report that sync works again after a non-final `reportError`. */ + reportError: (error: unknown) => void + /** Report that sync works again after `reportError`. */ reportHealthy: () => void /** * Replace the synced rows. By default the replacement also marks the diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 67331f80f7..ebada7dbe8 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -85,8 +85,10 @@ export interface LaggingSourceHandle< export interface HealthSourceHandle< T extends { id: string } = Row, > extends SourceHandle { - /** The sync adapter reports a health error after Collection readiness. */ - reportError: (error: unknown, options?: { final?: boolean }) => void + /** The sync adapter reports a health error and keeps trying. */ + reportError: (error: unknown) => void + /** The sync adapter gives up: a final health error after readiness. */ + markError: (error: unknown) => void /** The sync adapter reports that sync works again. */ reportHealthy: () => void } diff --git a/packages/db/tests/conformance/health-source.ts b/packages/db/tests/conformance/health-source.ts index 2813b75b18..a77511cbfd 100644 --- a/packages/db/tests/conformance/health-source.ts +++ b/packages/db/tests/conformance/health-source.ts @@ -18,7 +18,8 @@ export function makeHealthSource( write: (message: { type: string; value: T }) => void commit: () => void markReady: () => void - reportError: (error: unknown, options?: { final?: boolean }) => void + markError: (error: unknown) => void + reportError: (error: unknown) => void reportHealthy: () => void } | undefined @@ -46,7 +47,8 @@ export function makeHealthSource( insert: (row) => writeRows(`insert`, [row]), update: (row) => writeRows(`update`, [row]), remove: (row) => writeRows(`delete`, [row]), - reportError: (error, options) => controls!.reportError(error, options), + reportError: (error) => controls!.reportError(error), + markError: (error) => controls!.markError(error), reportHealthy: () => controls!.reportHealthy(), } } diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 1017ec81a9..ee8c182c10 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1179,7 +1179,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { [ `a health error that is not final`, () => { - source.reportError(new Error(`stream stalled`), { final: false }) + source.reportError(new Error(`stream stalled`)) report(`stream stalled`, false) }, ], @@ -1193,7 +1193,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { [ `a final health error`, () => { - source.reportError(new Error(`stream ended`), { final: true }) + source.markError(new Error(`stream ended`)) report(`stream ended`, true) }, ], @@ -1207,7 +1207,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { [ `a later report cannot replace a final health error`, () => { - source.reportError(new Error(`late`), { final: false }) + source.reportError(new Error(`late`)) report(`late`, false) }, ], diff --git a/packages/db/tests/fixtures/development-only-messages.json b/packages/db/tests/fixtures/development-only-messages.json index 3bffee9e1a..6624b4b3a3 100644 --- a/packages/db/tests/fixtures/development-only-messages.json +++ b/packages/db/tests/fixtures/development-only-messages.json @@ -1,12 +1,4 @@ [ - { - "file": "collection/lifecycle.ts", - "literals": [ - "[TanStack DB] Collection \"", - "\" called markError() after Collection readiness. ", - "Call reportError(error, { final: true }) instead; the status stays ready." - ] - }, { "file": "collection/sync.ts", "literals": [ diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index ce9063cccf..a217ed5f06 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -11,10 +11,12 @@ * throws. Only a restart (`cleanup()`, then a new preload or subscriber) * begins a new sync run. * - **Health** says whether the data is still kept up to date. It is metadata - * after Collection readiness: `reportError(error, { final })` records a problem, - * `reportHealthy()` clears a non-final one, and `markError()` after Collection readiness is - * a final problem. The status and the rows do not change. Only the sync adapter - * clears its problem, and nothing clears a final problem except a restart. + * after Collection readiness: `reportError(error)` records a problem while + * the sync adapter keeps trying, `reportHealthy()` clears it, and + * `markError()` after Collection readiness records a final problem, because + * the sync adapter gave up. The status and the rows do not change. Only the + * sync adapter clears its problem, and nothing clears a final problem + * except a restart. * * A live query exposes both axes for everything it depends on through * `collection.errors`: one node for itself and one per source alias in query @@ -59,8 +61,8 @@ * ## The history grammar * * Steps act on Collection `A` or `B`: `ready` writes the Collection's row and calls - * `markReady()`; `fail` calls `markError()`; `report` and `reportFinal` call - * `reportError()`; `healthy` calls `reportHealthy()`; `crash` moves the + * `markReady()`; `fail` calls `markError()`; `report` calls `reportError()`; + * `healthy` calls `reportHealthy()`; `crash` moves the * Collection to `error` through its lifecycle, as a crash cause would. `restart` cleans up the * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw @@ -125,7 +127,7 @@ type Shape = `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` type Step = | { - kind: `ready` | `fail` | `report` | `reportFinal` | `healthy` | `crash` + kind: `ready` | `fail` | `report` | `healthy` | `crash` source: SourceName } | { kind: `restart` } @@ -226,13 +228,9 @@ function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { source.problem = { final: true, serial: ++model.serial } break case `report`: - case `reportFinal`: if (source.gate !== `ready`) outcome = `throws` else if (!source.problem?.final) - source.problem = { - final: step.kind === `reportFinal`, - serial: ++model.serial, - } + source.problem = { final: false, serial: ++model.serial } break case `healthy`: if (source.gate !== `ready`) outcome = `throws` @@ -604,9 +602,7 @@ async function runHistory( new Error(`${step.source} crashed`), ) } else { - params.reportError(new Error(`${step.source} problem`), { - final: step.kind === `reportFinal`, - }) + params.reportError(new Error(`${step.source} problem`)) } } } catch (error) { @@ -708,21 +704,17 @@ const namedHistories: Record> = { on(`report`, `A`), on(`healthy`, `A`), ], - 'a final problem stays until a restart': [ + 'markError after Collection readiness stays until a restart': [ on(`ready`, `A`), on(`ready`, `B`), - on(`reportFinal`, `A`), + on(`report`, `A`), + on(`fail`, `A`), + on(`report`, `A`), on(`healthy`, `A`), restart, on(`ready`, `A`), on(`ready`, `B`), ], - 'markError after Collection readiness is final health': [ - on(`ready`, `A`), - on(`ready`, `B`), - on(`fail`, `A`), - on(`healthy`, `A`), - ], 'an initial error is final for its sync run': [ on(`fail`, `A`), on(`ready`, `A`), @@ -768,7 +760,6 @@ describe(`source failure and health oracle`, () => { `ready` as const, `fail` as const, `report` as const, - `reportFinal` as const, `healthy` as const, `crash` as const, ), diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 3b604e15a9..3aee54e144 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1759,8 +1759,6 @@ function createElectricSync>( commit: commitSyncTransaction, markReady, markError, - reportError, - reportHealthy: _reportHealthy, truncate, collection, metadata, @@ -2321,13 +2319,11 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - const error = new Error( - `Electric resume state referenced an unseen row; a full snapshot is required`, + markError( + new Error( + `Electric resume state referenced an unseen row; a full snapshot is required`, + ), ) - // The stream stops here, so after Collection readiness it is a final - // health error; only a restart refetches the snapshot. - if (collection.status === `ready`) reportError(error, { final: true }) - else markError(error) } const processMessages = (messages: Array>): void => { @@ -2657,16 +2653,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - if (collection.status !== `error`) { - if (collection.status === `ready`) { - // Post-readiness transaction failure: sync adapter gives up - // and will not retry. - reportError(error, { final: true }) - } else { - // Pre-readiness failure - markError(error) - } - } + if (collection.status !== `error`) markError(error) }, ) } @@ -2724,12 +2711,8 @@ function createElectricSync>( // retry loop. No provider batch will settle the gate. rejectFullSnapshot(error) if (isActiveLifecycle() && !abortController.signal.aborted) { - if (collection.status === `loading`) { - failInitialSync(error) - } else if (collection.status === `ready`) { - // Post-readiness stream failure: SDK gives up and will not retry - reportError(error, { final: true }) - } + if (collection.status === `loading`) failInitialSync(error) + else if (collection.status === `ready`) markError(error) } }, ) @@ -2754,14 +2737,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - if (collection.status === `ready`) { - // Post-readiness resume failure: adapter cannot proceed with invalid - // resume state and will not retry - reportError(error, { final: true }) - } else { - // Pre-readiness failure - markError(error) - } + markError(error) }, ) } diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index ffc9fcf719..c6e72f109f 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -841,7 +841,7 @@ function createPowerSyncCollectionConfig< // Cleanup owns the outcome. } else if (collection.status === `ready`) { trackingOutage = true - reportError(error, { final: false }) + reportError(error) } else markError(error) throw error }) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index d4b25e9ed5..22bc82d972 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -2796,7 +2796,7 @@ export function queryCollectionOptions( error, ) if (collection.status === `ready`) { - reportError(error, { final: false }) + reportError(error) } if (collection.status === `loading`) { markError(error) @@ -3173,7 +3173,7 @@ export function queryCollectionOptions( if (collection.status === `loading`) { markError(result.error) } else if (collection.status === `ready`) { - reportError(result.error, { final: false }) + reportError(result.error) } } } diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 7db3f4d800..1cfcbd00ee 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1009,10 +1009,7 @@ describe(`Query Collections`, () => { }) methods.commit() methods.markReady() - failSource = () => - methods.reportError(new Error(`source sync failed`), { - final: true, - }) + failSource = () => methods.markError(new Error(`source sync failed`)) }, }, }) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 6a75b7e283..cf96a15843 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -185,15 +185,7 @@ export function trailBaseCollectionOptions< type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { - begin, - write, - commit, - markReady, - markError, - reportError, - collection, - } = params + const { begin, write, commit, markReady, markError, collection } = params let cancelled = false let periodicCleanupTask: ReturnType | undefined const cleanup = () => { @@ -405,12 +397,12 @@ export function trailBaseCollectionOptions< } } + // The adapter does not resubscribe, so it gives up when its stream + // ends; a failed sync run already reported why it ended. function reportStreamEnd(error: unknown, closed = false) { if (cancelled) return - if (collection.status === `loading`) markError(error) - else if (collection.status === `ready`) - reportError(error, { final: true }) - // A failed sync run already reported why it ended. + if (collection.status === `loading` || collection.status === `ready`) + markError(error) else if (!closed) console.error(`TrailBase subscription failed`, error) } @@ -438,8 +430,6 @@ export function trailBaseCollectionOptions< subscribedReader.releaseLock() if (eventReader === subscribedReader) eventReader = undefined }) - // The adapter does not resubscribe, so any end of the stream it - // did not cancel stops change delivery for this sync run. .then( () => reportStreamEnd( @@ -466,12 +456,7 @@ export function trailBaseCollectionOptions< if (cancelled) return settleListener({ error }) cancelEventReader() - if (collection.status === `loading`) { - markError(error) - } else if (collection.status === `ready`) { - // Post-readiness stream failure: adapter does not resubscribe - reportError(error, { final: true }) - } + reportStreamEnd(error, true) return } From 16ff7fe5184c978f0c933c4055cda26a6a6d2aa6 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 21:42:01 -0700 Subject: [PATCH 049/106] refactor!: report infinite-query page failures in the error tree only The window controller kept its own copy of the last pagination failure: it set the infinite hook's status to error, froze hasNextPage, and exposed an error field. A failed page load already reaches the error tree as a request error, and after readiness a failed request must not change the status. Drop the controller's failure state and the hooks' error field; status, isError, and hasNextPage now follow the live query. The infinite-query oracle and the controller tests now expect the ready status after a failed window request, the infinite contract reads isError, and Svelte's infinite hook returns the errors tree its type already promised. The framework overview pages point to errors. Co-authored-by: Isaac --- docs/framework/react/overview.md | 2 +- docs/framework/svelte/overview.md | 2 +- docs/framework/vue/overview.md | 2 +- docs/guides/live-queries.md | 3 +- .../db/src/live-query-window-controller.ts | 107 ++---------------- .../db/tests/conformance/infinite-contract.ts | 2 +- .../conformance/infinite-suite-oracle.ts | 49 ++++---- .../live-query-window-controller.test.ts | 28 ++--- packages/react-db/src/useLiveInfiniteQuery.ts | 3 - .../tests/infinite-query-conformance.test.tsx | 2 +- .../tests/useLiveInfiniteQuery.test.tsx | 6 +- .../src/useLiveInfiniteQuery.svelte.ts | 6 +- .../infinite-query-conformance.svelte.test.ts | 2 +- packages/vue-db/src/useLiveInfiniteQuery.ts | 3 - .../tests/infinite-query-conformance.test.ts | 2 +- 15 files changed, 59 insertions(+), 160 deletions(-) diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 484dd6b56d..12d150e98e 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -180,7 +180,7 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( ) ``` -`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` value and do not reject the promise. +`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in the returned `errors` tree. This hook widens an ordered live query; it is not TanStack Query's `useInfiniteQuery`. `getNextPageParam` was previously ignored and is now rejected. diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index 5eeea14c81..ee6f6126d3 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -103,7 +103,7 @@ For ordered, paginated data with live updates, use `useLiveInfiniteQuery`: {/if} ``` -`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through `query.error` and do not reject the promise. +`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in `query.errors`. The query must include `orderBy`. The dependency array is available only with the query-function form. You can also pass an ordered, pre-created live query diff --git a/docs/framework/vue/overview.md b/docs/framework/vue/overview.md index db8cddcbb9..b3dda7ab32 100644 --- a/docs/framework/vue/overview.md +++ b/docs/framework/vue/overview.md @@ -80,7 +80,7 @@ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = ``` -`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` ref and do not reject the promise. +`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in the returned `errors` ref. The query must include `orderBy`. The dependency array is available only with the query-function form. You can also pass an ordered, pre-created live query diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 013ed14e0c..95a429d3f7 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -2357,7 +2357,8 @@ const effect = createEffect({ | `onExit` | `(event, ctx) => void \| Promise` (optional) | Called once for each row exiting the query result. | | `onBatch` | `(events, ctx) => void \| Promise` (optional) | Called once per graph run with the full unfiltered batch of delta events. | | `onError` | `(error, event) => void` (optional) | Called when `onEnter`, `onUpdate`, `onExit`, or `onBatch` throws or rejects. | -| `onSourceError` | `(error) => void` (optional) | Called when a source collection enters an error or cleaned-up state. The effect is automatically disposed after this fires. If not provided, the error is logged to `console.error`. | +| `onHealthChange` | `(errors) => void` (optional) | Called with the Effect's error tree when a source's health or a request changes after the Effect's initial load. | +| `onSourceError` | `(error) => void` (optional) | Called when a source collection enters an error state (an initial error or a crash) or is cleaned up. The effect is automatically disposed after this fires. If not provided, the error is logged to `console.error`. | | `skipInitial` | `boolean` (optional) | When `true`, deltas from the initial data load are suppressed. Only subsequent changes fire handlers. Defaults to `false`. | ### Delta Events diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 09fe6e49f6..308c40b7cd 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -2,11 +2,7 @@ import { LiveQueryWindowControllerDisposedError, SetWindowRequiresOrderByError, } from './errors.js' -import { - getLiveQueryStatusFlags, - isCollection, - isSingleResultCollection, -} from './live-query-adapter.js' +import { isCollection, isSingleResultCollection } from './live-query-adapter.js' import { createLiveQueryObserver } from './live-query-observer.js' import { BaseQueryBuilder } from './query/builder/index.js' import { deepEquals } from './utils.js' @@ -522,8 +518,6 @@ export interface LiveQueryWindowSnapshot< pageParams: ReadonlyArray hasNextPage: boolean isFetchingNextPage: boolean - /** Last pagination failure, retained through an earlier-started success until recovery begins. */ - error: unknown /** Keyed results for the physical window, or `undefined` when disabled. */ state: ReadonlyMap | undefined collection: Collection | undefined @@ -586,10 +580,6 @@ interface CachedFrom { observerSnapshot: unknown committedPageCount: number isFetchingNextPage: boolean - hasPaginationError: boolean - paginationError: unknown - failedHasNextPage: boolean - errors: unknown } interface SubscriptionRecord { @@ -614,9 +604,6 @@ class LiveQueryWindowControllerImpl< private committedPageCount: number private isFetchingNextPage = false - private hasPaginationError = false - private paginationError: unknown - private failedHasNextPage = false private activeFetchPromise: Promise | null = null private windowGeneration = 0 private pendingWindowGeneration: number | undefined @@ -659,20 +646,13 @@ class LiveQueryWindowControllerImpl< getSnapshot(): LiveQueryWindowSnapshot { const observerSnapshot = this.observer.getSnapshot() - if (this.hasPaginationError && observerSnapshot.status === `ready`) { - this.failedHasNextPage = this.getComputedHasNextPage(observerSnapshot) - } const cached = this.cachedSnapshot if ( cached && this.cachedFrom && this.cachedFrom.observerSnapshot === observerSnapshot && this.cachedFrom.committedPageCount === this.committedPageCount && - this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage && - this.cachedFrom.hasPaginationError === this.hasPaginationError && - this.cachedFrom.paginationError === this.paginationError && - this.cachedFrom.failedHasNextPage === this.failedHasNextPage && - this.cachedFrom.errors === observerSnapshot.errors + this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage ) { return cached } @@ -683,10 +663,7 @@ class LiveQueryWindowControllerImpl< ? (observerSnapshot.data as ReadonlyArray) : [] const totalRequested = this.committedPageCount * this.pageSize - const computedHasNextPage = enabled && rows.length > totalRequested - const hasNextPage = this.hasPaginationError - ? this.failedHasNextPage - : computedHasNextPage + const hasNextPage = enabled && rows.length > totalRequested const pageCount = enabled ? this.committedPageCount : 0 const pages: Array> = [] @@ -696,29 +673,24 @@ class LiveQueryWindowControllerImpl< pageParams.push(this.initialPageParam + i) } - const status = this.hasPaginationError ? `error` : observerSnapshot.status - const statusFlags = this.hasPaginationError - ? getLiveQueryStatusFlags(`error`) - : observerSnapshot this.cachedSnapshot = { data: rows.slice(0, totalRequested), pages, pageParams, hasNextPage, isFetchingNextPage: this.isFetchingNextPage, - error: this.hasPaginationError ? this.paginationError : undefined, get state() { return observerSnapshot.state }, collection: observerSnapshot.collection, - status, - isLoading: statusFlags.isLoading, - isReady: statusFlags.isReady, + status: observerSnapshot.status, + isLoading: observerSnapshot.isLoading, + isReady: observerSnapshot.isReady, persistedStatus: observerSnapshot.persistedStatus, isPersistedReady: observerSnapshot.isPersistedReady, persistedError: observerSnapshot.persistedError, - isIdle: statusFlags.isIdle, - isError: statusFlags.isError, + isIdle: observerSnapshot.isIdle, + isError: observerSnapshot.isError, isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, errors: observerSnapshot.errors, @@ -727,10 +699,6 @@ class LiveQueryWindowControllerImpl< observerSnapshot, committedPageCount: this.committedPageCount, isFetchingNextPage: this.isFetchingNextPage, - hasPaginationError: this.hasPaginationError, - paginationError: this.paginationError, - failedHasNextPage: this.failedHasNextPage, - errors: observerSnapshot.errors, } return this.cachedSnapshot } @@ -748,12 +716,10 @@ class LiveQueryWindowControllerImpl< // Store the desired physical window before observer activation can // compile or restart the live-query pipeline. const windowResult = this.ensureLeaseActive(this.committedPageCount) - const leaseGeneration = this.leaseGeneration observerUnsub = this.observer.subscribe(() => this.notify()) this.observerUnsub = observerUnsub - if (windowResult !== true) { - this.trackAttachmentFailure(windowResult, leaseGeneration) - } + // A failed load is a request error in the error tree. + if (windowResult !== true) void windowResult.catch(() => undefined) } catch (error) { observerUnsub?.() this.observerUnsub = null @@ -839,7 +805,6 @@ class LiveQueryWindowControllerImpl< if (this.disposed) return Promise.resolve() if ( this.committedPageCount === 1 && - !this.hasPaginationError && !this.isFetchingNextPage && !this.activeFetchPromise && this.pendingWindowGeneration === undefined @@ -853,22 +818,11 @@ class LiveQueryWindowControllerImpl< async preload(): Promise { if (this.disposed) throw new LiveQueryWindowControllerDisposedError() - const hadPaginationError = this.hasPaginationError - this.hasPaginationError = false - this.paginationError = undefined this.acquireInFlightLease() try { const result = this.ensureLeaseActive(this.committedPageCount) if (result !== true) await result await this.observer.preload() - this.failedHasNextPage = false - if (hadPaginationError) this.notify() - } catch (error) { - this.hasPaginationError = true - this.paginationError = error - this.failedHasNextPage = this.getComputedHasNextPage() - this.notify() - throw error } finally { this.releaseInFlightLease() } @@ -893,14 +847,11 @@ class LiveQueryWindowControllerImpl< fetchingNextPage: boolean, ): Promise { const generation = ++this.windowGeneration - const previousHasNextPage = this.getSnapshot().hasNextPage this.pendingWindowGeneration = undefined this.acquireInFlightLease() this.beginTransition() this.isFetchingNextPage = fetchingNextPage - this.hasPaginationError = false - this.paginationError = undefined if (fetchingNextPage) this.notify() let result: WindowResult @@ -908,9 +859,6 @@ class LiveQueryWindowControllerImpl< result = this.activateLease(requestedPageCount) } catch (error) { this.isFetchingNextPage = false - this.hasPaginationError = true - this.paginationError = error - this.failedHasNextPage = previousHasNextPage this.notify() this.endTransition() this.releaseInFlightLease() @@ -939,7 +887,6 @@ class LiveQueryWindowControllerImpl< this.pendingWindowGeneration = undefined this.committedPageCount = requestedPageCount this.isFetchingNextPage = false - this.failedHasNextPage = this.getComputedHasNextPage() this.notify() this.endTransition() }, @@ -948,9 +895,6 @@ class LiveQueryWindowControllerImpl< this.beginTransition() this.pendingWindowGeneration = undefined this.isFetchingNextPage = false - this.hasPaginationError = true - this.paginationError = error - this.failedHasNextPage = previousHasNextPage this.notify() this.endTransition() } @@ -997,37 +941,6 @@ class LiveQueryWindowControllerImpl< this.coordinator.release(this.lease, restoreWhenEmpty) } - private trackAttachmentFailure( - result: Promise, - leaseGeneration: number, - ): void { - void result.catch((error: unknown) => { - if ( - this.disposed || - !this.leaseActive || - leaseGeneration !== this.leaseGeneration - ) { - return - } - this.beginTransition() - this.hasPaginationError = true - this.paginationError = error - this.failedHasNextPage = this.getComputedHasNextPage() - this.notify() - this.endTransition() - }) - } - - private getComputedHasNextPage( - snapshot: LiveQuerySnapshot = this.observer.getSnapshot(), - ): boolean { - return ( - snapshot.isEnabled && - Array.isArray(snapshot.data) && - snapshot.data.length > this.committedPageCount * this.pageSize - ) - } - private beginTransition(): void { this.transitionDepth++ } diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index aa8a1f37fc..0f415b6f18 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -45,7 +45,7 @@ export interface InfiniteQueryResult { pageParams: Array hasNextPage: boolean isFetchingNextPage: boolean - error: unknown + isError: boolean status: string collection: Collection } diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 0cb95e8f33..ae41e16058 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -33,16 +33,15 @@ interface InfiniteRow { /** * A fulfilled page fetch advances the committed prefix. A failed request leaves - * that prefix alone and keeps its error visible until explicit recovery. - * Source length, not the controller's failed-window cache, decides whether the - * committed prefix has a continuation, including after later source writes. + * that prefix and the `ready` status alone; its rejection reaches the caller, + * and a failed source load behind it is a request error in the error tree. + * Source length decides whether the committed prefix has a continuation, + * including after later source writes. */ function expectedOverlappingWindow( source: ReadonlyArray, pageSize: number, pageSucceeded: boolean, - preloadFailed: boolean, - failure: Error, ) { const pageCount = pageSucceeded ? 2 : 1 const visible = source.slice(0, pageCount * pageSize) @@ -56,8 +55,6 @@ function expectedOverlappingWindow( pageParams: Array.from({ length: pageCount }, (_, page) => page), hasNextPage: source.length > pageCount * pageSize, isFetchingNextPage: !pageSucceeded, - error: preloadFailed ? failure : undefined, - status: preloadFailed ? `error` : `ready`, } } @@ -864,7 +861,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { scenario( `window-failure`, - `surfaces a failed window request in state without rejecting`, + `keeps pages and status after a failed window request`, async () => { const source = driver.makeSource(rows(8)) const handle = driver.mount( @@ -893,8 +890,8 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { await expect(handle.fetchNextPage()).resolves.toBeUndefined() await handle.flush() expect(handle.current().pages.map((page) => page.length)).toEqual([3]) - expect(handle.current().status).toBe(`error`) - expect(handle.current().error).toBe(failure) + expect(handle.current().status).toBe(`ready`) + expect(handle.current().isError).toBe(false) utils.setWindow = originalSetWindow await handle.fetchNextPage() @@ -902,7 +899,6 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { expect(handle.current().pages.map((page) => page.length)).toEqual([ 3, 3, ]) - expect(handle.current().error).toBeUndefined() expectPageRows(handle.current(), rows(8).slice(0, 6), 3) } finally { utils.setWindow = originalSetWindow @@ -912,13 +908,13 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { scenario( `overlapping-window-outcomes`, - `retains a window failure while continuation follows committed pages and live source changes`, + `keeps continuation on committed pages and live source changes after a window failure`, async () => { // Contract: after overlapping window requests settle, the latest - // committed page count determines continuation. A prior failure remains - // visible until explicit recovery. The bounded grammar crosses an - // exhausted versus remaining source with both settlement orders, then - // changes source extent while the earlier error remains visible. + // committed page count determines continuation, and a failed request + // changes neither the pages nor the status. The bounded grammar + // crosses an exhausted versus remaining source with both settlement + // orders, then changes source extent after the failure. // This drives the exported DB controller in each package realm. Hooks // expose no preload operation, so their scheduling is outside this cell. const pageSize = 2 @@ -970,13 +966,11 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { let secondReleased = false let preload: Promise | undefined let fetch: Promise | undefined - const check = (pageSucceeded: boolean, preloadFailed: boolean) => { + const check = (pageSucceeded: boolean) => { const expected = expectedOverlappingWindow( currentRows, pageSize, pageSucceeded, - preloadFailed, - failure, ) const actual = controller.getSnapshot() expect(actual.data.map((row) => row.id)).toEqual(expected.ids) @@ -988,8 +982,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { expect(actual.isFetchingNextPage).toBe( expected.isFetchingNextPage, ) - expect(actual.error).toBe(expected.error) - expect(actual.status).toBe(expected.status) + expect(actual.status).toBe(`ready`) checked++ } @@ -1009,20 +1002,20 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { firstReleased = true rejectFirst(failure) expect(await preload).toBe(failure) - check(false, true) + check(false) secondReleased = true resolveSecond() expect(await fetch).toBeUndefined() - check(true, true) + check(true) } else { secondReleased = true resolveSecond() expect(await fetch).toBeUndefined() - check(true, false) + check(true) firstReleased = true rejectFirst(failure) expect(await preload).toBe(failure) - check(true, true) + check(true) } const fifthRow = rowCount === 4 @@ -1040,7 +1033,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { await waitForAsync( () => collection.toArray.length === currentRows.length, ) - check(true, true) + check(true) } finally { unsubscribe() } @@ -1055,9 +1048,9 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { await waitForAsync( () => collection.toArray.length === currentRows.length, ) - check(true, true) + check(true) await controller.preload() - check(true, false) + check(true) } finally { if (!firstReleased) rejectFirst(failure) if (!secondReleased) resolveSecond() diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 169294d36f..0c77aafc11 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -496,16 +496,13 @@ describe(`createLiveQueryWindowController`, () => { expect(controller.getSnapshot().pages).toHaveLength(1) expect(controller.getSnapshot().hasNextPage).toBe(true) - expect((controller.getSnapshot() as { error?: unknown }).error).toBe( - failure, - ) await controller.fetchNextPage() expect(controller.getSnapshot().pages).toHaveLength(2) controller.dispose() }) - it(`clears a preload error after a successful retry`, async () => { + it(`keeps a rejected preload out of the window status`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const failure = new Error(`preload failed`) vi.spyOn(lq.utils, `setWindow`).mockRejectedValueOnce(failure) @@ -514,11 +511,10 @@ describe(`createLiveQueryWindowController`, () => { }) await expect(controller.preload()).rejects.toBe(failure) - expect(controller.getSnapshot().error).toBe(failure) + expect(controller.getSnapshot().isError).toBe(false) await controller.preload() expect(controller.getSnapshot().isError).toBe(false) - expect(controller.getSnapshot().error).toBeUndefined() controller.dispose() }) @@ -606,7 +602,7 @@ describe(`createLiveQueryWindowController`, () => { expect(await expansionOutcome).toBe(failure) expect(await preloadOutcome).toBe(failure) expect(controller.getSnapshot().pages).toHaveLength(1) - expect(controller.getSnapshot().error).toBe(failure) + expect(controller.getSnapshot().isError).toBe(false) await controller.fetchNextPage() } else { resolveExpansion() @@ -713,7 +709,11 @@ describe(`createLiveQueryWindowController`, () => { rejectLoads = true await expect(controller.fetchNextPage()).rejects.toBe(failure) expect(controller.getSnapshot().pages).toHaveLength(1) - expect(controller.getSnapshot().error).toBe(failure) + // The failed load is a request error; the window keeps its status. + expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().errors?.error).toMatchObject({ + phase: `request`, + }) expect(lq.utils.lastSubsetError).toBe(failure) expect(lq.utils.customUtility()).toBe(`custom`) controller.dispose() @@ -859,7 +859,7 @@ describe(`createLiveQueryWindowController`, () => { if (outcome === `reject`) { expect(await resetOutcome).toBe(failure) expect(await expansionOutcome).toBe(failure) - expect(controller.getSnapshot().error).toBe(failure) + expect(controller.getSnapshot().isError).toBe(false) expect(ids(controller.getSnapshot())).toEqual([`1`, `2`]) await controller.reset() } else { @@ -1268,7 +1268,6 @@ describe(`createLiveQueryWindowController`, () => { await flush() expect(controller.getSnapshot().isError).toBe(false) - expect(controller.getSnapshot().error).toBeUndefined() controller.dispose() }) @@ -1430,7 +1429,7 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) - it(`derives status flags from a pagination error status`, async () => { + it(`keeps ready status flags after a failed page request`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { pageSize: 2, @@ -1443,12 +1442,13 @@ describe(`createLiveQueryWindowController`, () => { ) await expect(controller.fetchNextPage()).rejects.toThrow(`window failed`) + // A failed request after readiness is health, not status. expect(controller.getSnapshot()).toMatchObject({ - status: `error`, + status: `ready`, isLoading: false, - isReady: false, + isReady: true, isIdle: false, - isError: true, + isError: false, isCleanedUp: false, }) controller.dispose() diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 245ad90e1f..c2e1a5d1f4 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -76,7 +76,6 @@ export type UseLiveInfiniteQueryReturn = Omit< fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean - error: unknown } export type UseLiveInfiniteQueryReturnWithCollection< @@ -102,7 +101,6 @@ export type UseLiveInfiniteQueryReturnWithCollection< fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean - error: unknown errors: ErrorTreeNode | undefined } @@ -472,7 +470,6 @@ export function useLiveInfiniteQuery( fetchNextPage, hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, - error: snapshot.error, errors: snapshot.errors, } } diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index a00fd70f2a..50303827ad 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -94,7 +94,7 @@ function makeHandle( pageParams: result.pageParams, hasNextPage: result.hasNextPage, isFetchingNextPage: result.isFetchingNextPage, - error: result.error, + isError: result.isError, status: result.status, collection: result.collection, } diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 1e67758e2f..f0f7f049d9 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -193,7 +193,7 @@ describe(`useLiveInfiniteQuery`, () => { ).toThrow(/orderBy/) }) - it(`resolves the fetch promise and exposes pagination failures in state`, async () => { + it(`resolves the fetch promise and keeps status after a pagination failure`, async () => { const source = createCollection( mockSyncCollectionOptions({ id: `infinite-query-pagination-failure`, @@ -226,9 +226,9 @@ describe(`useLiveInfiniteQuery`, () => { }) await waitFor(() => { - expect(result.current.isError).toBe(true) - expect(result.current.error).toBe(failure) + // A failed page request after readiness does not change the status. expect(result.current.isFetchingNextPage).toBe(false) + expect(result.current.isError).toBe(false) }) expect(result.current.pages).toHaveLength(1) expect(result.current.hasNextPage).toBe(true) diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 40d937c652..51a3d94d73 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -58,7 +58,6 @@ export type UseLiveInfiniteQueryReturn = Omit< fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean - error: unknown } export type UseLiveInfiniteQueryReturnWithCollection< @@ -75,7 +74,6 @@ export type UseLiveInfiniteQueryReturnWithCollection< fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean - error: unknown } type EnabledLiveQueryReturn = ReturnType< @@ -282,8 +280,8 @@ export function useLiveInfiniteQuery( get isFetchingNextPage() { return snapshot.isFetchingNextPage }, - get error() { - return snapshot.error + get errors() { + return snapshot.errors }, fetchNextPage, } diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts index 601061cb2a..689a129380 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -113,7 +113,7 @@ function makeHandle( pageParams: result.pageParams, hasNextPage: result.hasNextPage, isFetchingNextPage: result.isFetchingNextPage, - error: result.error, + isError: result.isError, status: result.status, collection: result.collection, } diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 454f47902c..75d8990f2f 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -68,7 +68,6 @@ export interface UseLiveInfiniteQueryReturn< fetchNextPage: () => Promise hasNextPage: ComputedRef isFetchingNextPage: ComputedRef - error: ComputedRef errors: ComputedRef } @@ -94,7 +93,6 @@ export interface UseLiveInfiniteQueryReturnWithCollection< fetchNextPage: () => Promise hasNextPage: ComputedRef isFetchingNextPage: ComputedRef - error: ComputedRef errors: ComputedRef } @@ -271,7 +269,6 @@ export function useLiveInfiniteQuery< fetchNextPage: () => fetchNextLiveQueryWindowPage(controller.value), hasNextPage: computed(() => snapshot.value.hasNextPage), isFetchingNextPage: computed(() => snapshot.value.isFetchingNextPage), - error: computed(() => snapshot.value.error), errors: computed(() => snapshot.value.errors), } } diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index a8828f86b1..f3caa8b850 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -130,7 +130,7 @@ function makeHandle( pageParams: result.pageParams.value, hasNextPage: result.hasNextPage.value, isFetchingNextPage: result.isFetchingNextPage.value, - error: result.error.value, + isError: result.isError.value, status: result.status.value, collection: result.collection.value, } From 6f1c80c7ee17c5c0a3cf912b0a015369c70fac56 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 21:53:31 -0700 Subject: [PATCH 050/106] refactor(query-db-collection)!: report Query failures only through Collection health utils.lastError, utils.isError, and utils.errorCount kept a second copy of the failure state that Collection health already reports, and clearError() was refetch({ throwOnError: true }). Remove all four. The adapter keeps its error revision to decide when a successful application may report healthy. The tests read a reportedError helper: the initial error while the Collection is in error, or its health error's cause after readiness. The ownership oracle's retry model now keeps an initial error for the whole sync run and clears only a health error on success; tests that pinned retained errors after cleanup or a successful refetch now expect the final initial error or its reset. Docs, the query adapter skill reference, and the changeset describe the replacement. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 6 +- docs/collections/query-collection.md | 24 ++- docs/guides/error-handling.md | 45 +++-- docs/guides/offline-transactions.md | 2 +- .../references/query-adapter.md | 2 +- packages/query-db-collection/src/query.ts | 67 ++----- .../load-subset-lifecycle-oracle.test.ts | 57 +++--- .../tests/ownership-lifecycle.oracle.test.ts | 163 +++++++++--------- .../query-db-collection/tests/query.test.ts | 134 +++++++------- 9 files changed, 226 insertions(+), 274 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 181737eb32..3a1d1617b3 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -18,8 +18,10 @@ Collections now separate two questions: whether data can be shown, and whether i Breaking changes and migration: -- An initial error is final for its sync run. A later successful sync no longer moves a Collection from `error` back to `ready`. Restart instead: `await collection.cleanup()` then `await collection.preload()`. For query-db, a successful refetch or `utils.clearError()` after an initial failure clears `utils.lastError` but leaves the status `error` until a restart. +- An initial error is final for its sync run. A later successful sync no longer moves a Collection from `error` back to `ready`. Restart instead: `await collection.cleanup()` then `await collection.preload()`. For query-db, a successful refetch after an initial failure applies its rows but leaves the status `error` until a restart. - After a Collection is ready, sync failures no longer set `status` to `error` or `isError` to `true`. To show a "data may be out of date" message, read `errors.hasErrors` from the hook, or `collection.health`. - A Collection that can no longer serve reads or accept writes crashes: a closed IndexedDB connection or a failed local SQLite persistence write moves it to `error` even after it was ready. Every live query and Effect over a Collection in `error` now fails too, whether the cause is an initial error or a crash. - Adapter authors: call `markError(error)` when you give up. Before the first `markReady()` it fails the sync run; after it, it is a final health error and the rows stay. While you keep trying after readiness, call `reportError(error)`, then `reportHealthy()` when sync works again. `markReady()` after `markError()` in the same sync run now throws. -- Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db mirrors `utils.lastError` into Collection health after readiness. +- Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db reports a failed refetch or result application after readiness as a health error that clears on the next successful result. +- query-db removes `utils.lastError`, `utils.isError`, `utils.errorCount`, and `utils.clearError()`. Read `collection.health` or a live query's `errors` tree instead, and retry with `utils.refetch({ throwOnError: true })`. +- Infinite-query hooks (`useLiveInfiniteQuery` in React, Vue, and Svelte) remove `error`. A failed page load no longer sets `status` to `error`; it appears as a request error in the returned `errors` tree, and `fetchNextPage()` still resolves. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index a23026416b..d688f99b7c 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -388,8 +388,8 @@ In eager mode, direct write utilities such as `writeInsert`, `writeUpdate`, and In both modes, a direct cache patch leaves Query's error, invalidation, and freshness state intact. A direct write is not a successful fetch: an eager query that failed still reports that error, and a stale eager query may fetch -on remount. Use `utils.clearError()` to retry after an error, or -`utils.refetch()` when you need fresh server results. +on remount. Use `utils.refetch({ throwOnError: true })` to retry after an +error, or `utils.refetch()` when you need fresh server results. This works automatically when `select` returns the wrapper's array property itself, as in these simple wrappers: @@ -637,8 +637,8 @@ starts first. It includes calls through the handler parameter, captured or external Collection references, manual transaction mutation functions, and `createOptimisticAction` mutation functions. Code can await the mutation's `isPersisted.promise` when it needs the complete transaction and its queued -Collection application. Utilities that refetch, including `clearError()`, -inherit the same phase boundary. +Collection application. Utilities that refetch inherit the same phase +boundary. The handler's `collection` parameter and every matching `transaction.mutations[n].collection` alias preserve the external Collection @@ -682,16 +682,12 @@ The collection provides these utility methods via `collection.utils`: - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) - Returns the tracked Queries' `QueryObserverResult` values in tracked-key order - Preserves an `undefined` slot if a tracked Query is removed while the refetch starts -- `clearError()`: Refetch with errors enabled; retain the recorded error until a successful result applies to the Collection - - Uses the same phase-dependent boundary as `refetch()`. During a mutation handler, the promise can resolve at the fetch boundary before the result applies and clears the error. - -`utils.lastError`, `utils.isError`, and `utils.errorCount` cover every Query the -Collection observes. After the Collection is ready, `collection.health` and each -live query's `errors` tree mirror them: a failed fetch or result application is -a health error that is not final, because TanStack Query fetches again, and the -next successful application clears both. A successful refetch does not move a -Collection whose initial load failed back to `ready`; restart it with -`cleanup()` and `preload()`. +Failures appear in Collection health. After the Collection is ready, a failed +fetch or result application for any Query it observes is a health error that +is not final, because TanStack Query fetches again, and the next successful +application clears it. Read it from `collection.health` or a live query's +`errors` tree. A successful refetch does not move a Collection whose initial +load failed back to `ready`; restart it with `cleanup()` and `preload()`. ## Direct Writes diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index d548558210..cc53ec28b5 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -71,7 +71,12 @@ For more details on schema validation and type transformations, see the [Schemas ## Query Collection Error Tracking -Query collections provide enhanced error tracking utilities through the `utils` object. These methods expose error state information and provide recovery mechanisms for failed queries: +Query collections report failures through Collection health, like every other +Collection. Before the first successful result, a failed query is an initial +error: the status is `error`, and readiness waits reject with the cause. After +it, a failed refetch or result application is a health error that is not +final, because TanStack Query fetches again. The rows stay, and the next +successful result clears the error: ```tsx import { createCollection } from "@tanstack/db" @@ -81,26 +86,25 @@ import { useLiveQuery } from "@tanstack/react-db" const syncedCollection = createCollection( queryCollectionOptions({ queryClient, - queryKey: ['synced-data'], + queryKey: ["synced-data"], queryFn: fetchData, getKey: (item) => item.id, }) ) -// Component can check error state function DataList() { - const { data } = useLiveQuery({ - query: (q) => q.from({ item: syncedCollection }), - }) - const isError = syncedCollection.utils.isError - const errorCount = syncedCollection.utils.errorCount - + const { data, errors } = useLiveQuery((q) => + q.from({ item: syncedCollection }) + ) + return ( <> - {isError && errorCount > 3 && ( + {errors?.hasErrors && ( Unable to sync. Showing cached data. - @@ -111,17 +115,10 @@ function DataList() { } ``` -Error tracking methods: -- **`lastError`**: Returns the most recent error encountered by the query, or `undefined` if no errors have occurred since a successful result applied: -- **`isError`**: Returns a boolean indicating whether the collection is currently in an error state: -- **`errorCount`**: Returns the number of consecutive sync failures. This counter is incremented only when queries fail completely (not per retry attempt) and is reset after a successful result applies: -- **`clearError()`**: Triggers a refetch. The current error remains visible until a successful result applies to the Collection. An applied result clears `lastError` and resets `errorCount`; a failed retry records another consecutive failure and rejects the returned promise. During a mutation handler, the promise can resolve at the fetch boundary before application clears the error: - - ```text - error visible → clearError() → retry pending, error still visible - ├─ applied success → error cleared - └─ failure → new error visible, count increased - ``` +`utils.refetch({ throwOnError: true })` retries and rejects when the retry +fails. The error stays visible while the retry is pending. During a mutation +handler, the promise can resolve at the fetch boundary before the result +applies and clears the error. ## Incremental Subset Load Errors @@ -661,8 +658,8 @@ When sync errors occur: - An initial failure marks the collection as `error` because no usable snapshot exists - Readiness waits such as `preload()` and `toArrayWhenReady()` reject with the cause passed to `markError(error)` while the collection is in that initial error state - A later refetch failure keeps the collection `ready` and preserves its cached data, and reports a health error that is not final, because TanStack Query fetches again -- Error tracking counters are updated (`lastError`, `errorCount`); after the collection is ready, `collection.health` and the `errors` tree mirror `lastError` -- A later successful refetch clears `lastError` and the health error, but it does not move an initial `error` collection back to `ready`; restart it with `cleanup()` and `preload()` +- After the collection is ready, the failure appears in `collection.health` and the `errors` tree +- A later successful refetch clears that health error, but it does not move an initial `error` collection back to `ready`; restart it with `cleanup()` and `preload()` ### Sync Write Errors diff --git a/docs/guides/offline-transactions.md b/docs/guides/offline-transactions.md index 5ea40eae44..17b512e26d 100644 --- a/docs/guides/offline-transactions.md +++ b/docs/guides/offline-transactions.md @@ -348,7 +348,7 @@ void transaction.when('settled').catch((error) => console.error(error)) For the leader, `waitForInit()` waits for the initial outbox read and optimistic restoration attempt. It does not make `todos` ready. The separate `preload()` call starts Collection loading without blocking offline actions. A screen that needs initial rows can await `todos.preload()` and handle a rejection. -The final `refetch()` asks the server for current rows after a successful write. By default, it can resolve when that read fails. The offline transaction can then settle successfully because the POST succeeded. That read error does not itself request an outbox retry. Check `todos.utils.isError` and `todos.utils.lastError` to report or retry the read. +The final `refetch()` asks the server for current rows after a successful write. By default, it can resolve when that read fails. The offline transaction can then settle successfully because the POST succeeded. That read error does not itself request an outbox retry. Check `todos.health` (or the `errors` tree of a live query over it) to report or retry the read. The [React Native offline transactions example](https://github.com/TanStack/db/tree/main/examples/react-native/offline-transactions) shows the full app structure. Its API client does not yet send an idempotency key, so use the request pattern above when you adapt it. The [shopping list example](https://github.com/TanStack/db/tree/main/examples/react-native/shopping-list) also shows Electric sync. diff --git a/packages/db/skills/db-core/collection-setup/references/query-adapter.md b/packages/db/skills/db-core/collection-setup/references/query-adapter.md index 3f0b07f08e..f0cef1b3f0 100644 --- a/packages/db/skills/db-core/collection-setup/references/query-adapter.md +++ b/packages/db/skills/db-core/collection-setup/references/query-adapter.md @@ -240,7 +240,7 @@ same exact key. Exact-key and prefix invalidation refetch active eager and on-demand queries, then rematerialize their results. Overlapping subsets keep rows that another active subset still owns. A failed refetch retains current rows and records the -error in `collection.utils.lastError`. Cleaned-up or otherwise inactive queries +error as a health error in `collection.health`. Cleaned-up or otherwise inactive queries do not rematerialize. ## Predicate Push-Down (syncMode: "on-demand") diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 22bc82d972..8b6a58e5b0 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -301,7 +301,8 @@ export interface QueryCollectionUtils< TItem extends object = Record, TKey extends string | number = string | number, TInsertInput extends object = TItem, - TError = unknown, + // Kept so existing four-argument references still type-check. + _TError = unknown, > { // Keep this interface closed: extending UtilsRecord would make every // nonexistent adapter utility appear as `any`. @@ -320,16 +321,8 @@ export interface QueryCollectionUtils< /** Execute direct writes as one atomic batch. Resolves when the sync commit applies. */ writeBatch: (callback: () => void) => Promise - // Query Observer State (getters) - /** Get the last error encountered by the query (if any); reset after a successful result applies */ - lastError: TError | undefined - /** Check if the collection is in an error state */ - isError: boolean - /** - * Get the number of consecutive sync failures. - * Incremented only when query fails completely (not per retry attempt); reset after a successful result applies. - */ - errorCount: number + // Query Observer State (getters). Failures appear in `collection.health` + // and each live query's `errors` tree. /** Check if query is currently fetching (initial or background) */ isFetching: boolean /** Check if query is refetching in background (not initial fetch) */ @@ -344,24 +337,12 @@ export interface QueryCollectionUtils< * `idle` when every observer is idle or no observers exist. */ fetchStatus: `fetching` | `paused` | `idle` - - /** - * Refetch, retaining errors until a successful result applies. While a user - * mutation is persisting or its handler is active, this retains the Query - * fetch boundary so it cannot wait on publication blocked by that - * transaction. - * @returns Promise that resolves when the applicable refetch boundary completes - * @throws Error if the refetch fails - */ - clearError: () => Promise } /** * Internal state object for tracking query observer and errors */ interface QueryCollectionState { - lastError: any - errorCount: number observers: Map< string, QueryObserver, any, Array, Array, any> @@ -426,27 +407,6 @@ class QueryCollectionUtilsImpl implements QueryCollectionUtils { this.writeBatch = writeUtils.writeBatch } - public async clearError() { - await this.refetch({ throwOnError: true }) - } - - // Getters for error state. They cover every Query this Collection observes: - // a failed fetch or result application sets them, and a later successful - // application clears them. After Collection readiness, Collection health - // mirrors them: the same failure is a health error that is not final, since - // TanStack Query fetches again, and the same clear reports healthy. - public get lastError() { - return this.state.lastError - } - - public get isError() { - return !!this.state.lastError - } - - public get errorCount() { - return this.state.errorCount - } - // Getters for QueryObserver state public get isFetching() { // check if any observer is fetching @@ -834,8 +794,6 @@ export function queryCollectionOptions( /** State object to hold error tracking and observer reference */ const state: QueryCollectionState = { - lastError: undefined as any, - errorCount: 0, observers: new Map< string, QueryObserver, any, Array, Array, any> @@ -1191,9 +1149,10 @@ export function queryCollectionOptions( const observedErrorUpdates = new WeakMap() let errorRevision = 0 const failedResultApplications = new Map() - const clearLastError = () => { - state.lastError = undefined - state.errorCount = 0 + // A failed fetch or result application after Collection readiness is a + // health error that is not final, since TanStack Query fetches again. A + // later successful application with no newer failure reports healthy. + const reportRecovered = () => { if (collection.status === `ready`) reportHealthy() } const exceptionalResultSettlements = new WeakMap< @@ -2781,15 +2740,13 @@ export function queryCollectionOptions( failedResultApplications.delete(hashedQueryKey) // Applying cached data during a fetch does not establish recovery. if (!isFetching && applicationErrorRevision === errorRevision) { - clearLastError() + reportRecovered() } } }, (error) => { if (!finish()) return failedResultApplications.set(hashedQueryKey, error) - state.lastError = error - state.errorCount++ errorRevision++ console.error( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, @@ -2840,7 +2797,7 @@ export function queryCollectionOptions( resultApplicationControllers.delete(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) if (!result.isFetching && currentErrorRevision === errorRevision) { - clearLastError() + reportRecovered() } } return @@ -3157,8 +3114,6 @@ export function queryCollectionOptions( if (observedQuery) { observedErrorUpdates.set(observedQuery, result.errorUpdateCount) } - state.lastError = result.error - state.errorCount++ errorRevision++ } @@ -3169,7 +3124,7 @@ export function queryCollectionOptions( // A failure before the first successful snapshot leaves no usable // collection state. Later refetch failures keep the last ready - // snapshot available while utils expose the error. + // snapshot available and report health. if (collection.status === `loading`) { markError(result.error) } else if (collection.status === `ready`) { diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index d6b0aa1db3..2a1d61ac72 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -14,6 +14,20 @@ import { queryCollectionOptions } from '../src/query.js' import type { QueryFunctionContext } from '@tanstack/query-core' import type { LoadSubsetOptions, SyncMetadataApi } from '@tanstack/db' +/** + * The Query failure a Collection reports: its initial error while it is in + * `error`, or the cause of its health error after readiness. + */ +function reportedError(collection: { + status: string + health: { error: { cause: unknown } | null } + _lifecycle: { getSyncError: () => unknown } +}): unknown { + return collection.status === `error` + ? collection._lifecycle.getSyncError() + : collection.health.error?.cause +} + /** * # Does Query-backed loadSubset preserve identity and lifecycle? * @@ -980,7 +994,7 @@ async function expectPeerJoiningHeldFetchRecovers( if (expectNewRow) expect(peerOutcome).toBe(`fulfilled`) if (!peerJoins) expect(peerOutcome).toBe(`not-started`) if (failure === `unrelated`) { - expect(collection.utils.lastError).toBe(unrelatedError) + expect(reportedError(collection)).toBe(unrelatedError) expect(peerOutcome).toBe(phase === `initial` ? `rejected` : `fulfilled`) } if (!peerJoins || failure === `unrelated`) expect(calls).toBe(heldCall) @@ -1159,13 +1173,12 @@ async function expectForeignOwnerReleaseKeepsInFlightSignal(): Promise { } /** - * After Collection readiness, Collection health mirrors `utils.lastError`. - * A failed refetch sets both: the health error is not final, because TanStack - * Query fetches again, and the status and rows stay. The next successful - * application clears both. The model is that one rule; it does not read the - * adapter's per-key bookkeeping. + * After Collection readiness, a failed refetch is a health error that is not + * final, because TanStack Query fetches again, and the status and rows stay. + * The next successful application clears it. The model is that one rule; it + * does not read the adapter's per-key bookkeeping. */ -async function expectRefetchFailureHealthFollowsLastError(): Promise { +async function expectRefetchFailureIsHealth(): Promise { const error = new Error(`refetch failed`) const queryClient = createQueryClient() const id = `load-subset-health-${collectionSequence++}` @@ -1187,7 +1200,6 @@ async function expectRefetchFailureHealthFollowsLastError(): Promise { ) const observe = () => ({ status: collection.status, - lastError: collection.utils.lastError, health: collection.health.error && { phase: collection.health.error.phase, final: collection.health.error.final, @@ -1199,25 +1211,22 @@ async function expectRefetchFailureHealthFollowsLastError(): Promise { await collection.preload() expect(observe()).toEqual({ status: `ready`, - lastError: undefined, health: null, rows: [`first`], }) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.utils.lastError).toBe(error)) + await vi.waitFor(() => expect(collection.health.isError).toBe(true)) expect(observe()).toEqual({ status: `ready`, - lastError: error, health: { phase: `sync`, final: false, cause: error }, rows: [`first`], }) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.utils.lastError).toBeUndefined()) + await vi.waitFor(() => expect(collection.health.isError).toBe(false)) expect(observe()).toEqual({ status: `ready`, - lastError: undefined, health: null, rows: [`second`], }) @@ -1259,8 +1268,8 @@ async function expectInitialQueryFailureStatus(): Promise { try { await vi.waitFor(() => { - expect(collection.utils.lastError).toBe(error) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBe(error) + expect(reportedError(collection)).toBeDefined() }) expect(loggedError).toHaveBeenCalled() try { @@ -1274,13 +1283,13 @@ async function expectInitialQueryFailureStatus(): Promise { throw new TraceAssertionError(0, caught) } - // An initial error is final for its sync run. A successful refetch clears - // utils.lastError and applies its rows, but only a restart reaches ready. - await collection.utils.clearError() + // An initial error is final for its sync run. A successful refetch + // applies its rows, but the error stays until a restart reaches ready. + await collection.utils.refetch({ throwOnError: true }) await vi.waitFor(() => { - expect(collection.utils.lastError).toBeUndefined() expect(collection.get(`recovered`)).toBeDefined() }) + expect(reportedError(collection)).toBe(error) expect(collection.status).toBe(`error`) await collection.cleanup() await live.cleanup() @@ -1453,7 +1462,7 @@ async function expectRefetchFailureKeepsReadySnapshot(): Promise { await live.preload() await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError).toBe(error) + expect(reportedError(collection)).toBe(error) }) expect(collection.status).toBe(`ready`) expect(live.status).toBe(`ready`) @@ -1561,7 +1570,7 @@ async function expectDeferredStartupReadyDoesNotOverrideError(): Promise { await maintenanceDeleted for (let turn = 0; turn < 10; turn++) await Promise.resolve() expect(collection.status).toBe(`error`) - expect(collection.utils.lastError).toBe(queryError) + expect(reportedError(collection)).toBe(queryError) } finally { if (typeof secondSync === `function`) { await secondSync() @@ -2474,7 +2483,7 @@ async function observeLateRetiredQuery( live: values(current.toArray), sourceStatus: source.status, liveStatus: current.status, - sourceError: source.utils.lastError, + sourceError: reportedError(source), }) } const sourceSubscription = source.subscribeChanges(capture) @@ -2641,8 +2650,8 @@ describe(`loadSubset lifecycle oracle`, () => { await expectReleasedRefetchRejects() }) - it(`mirrors utils.lastError into Collection health after readiness`, async () => { - await expectRefetchFailureHealthFollowsLastError() + it(`reports a failed refetch after readiness as Collection health`, async () => { + await expectRefetchFailureIsHealth() }) it(`keeps an initial query failure until a restart, even after a successful refetch`, async () => { diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index cc042a1b6c..59d33052df 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -43,6 +43,20 @@ import type { import type { NonSingleResult } from '../../db/src/types.js' import type { QueryCollectionUtils } from '../src/query.js' +/** + * The Query failure a Collection reports: its initial error while it is in + * `error`, or the cause of its health error after readiness. + */ +function reportedError(collection: { + status: string + health: { error: { cause: unknown } | null } + _lifecycle: { getSyncError: () => unknown } +}): unknown { + return collection.status === `error` + ? collection._lifecycle.getSyncError() + : collection.health.error?.cause +} + /** * # Who owns a Query-backed row, and when may it disappear? * @@ -2664,7 +2678,7 @@ describe(`query collection ownership lifecycle`, () => { const refresh = ( operation === `refetch` ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.clearError() + : collection.utils.refetch({ throwOnError: true }) ).then( (result) => { refetchOutcome = `resolved` @@ -2796,7 +2810,7 @@ describe(`query collection ownership lifecycle`, () => { const refresh = ( operation === `refetch` ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.clearError() + : collection.utils.refetch({ throwOnError: true }) ).then( (result) => { refetchOutcome = `resolved` @@ -2929,7 +2943,7 @@ describe(`query collection ownership lifecycle`, () => { const refresh = operation === `refetch` ? collection.utils.refetch({ throwOnError: true }) - : collection.utils.clearError() + : collection.utils.refetch({ throwOnError: true }) await refresh.then( () => { refreshOutcome = `resolved` @@ -3046,7 +3060,7 @@ describe(`query collection ownership lifecycle`, () => { const refresh = operation === `refetch` ? capturedCollection.utils.refetch({ throwOnError: true }) - : capturedCollection.utils.clearError() + : capturedCollection.utils.refetch({ throwOnError: true }) await refresh.then( () => { refreshOutcome = `resolved` @@ -3292,7 +3306,7 @@ describe(`query collection ownership lifecycle`, () => { handlerCollection: { utils: Pick< QueryCollectionUtils, - `refetch` | `clearError` + `refetch` > }, transaction: { @@ -3361,7 +3375,7 @@ describe(`query collection ownership lifecycle`, () => { const boundary = operation === `refetch` ? refetchCollection.utils.refetch({ throwOnError: true }) - : refetchCollection.utils.clearError() + : refetchCollection.utils.refetch({ throwOnError: true }) await boundary.then( () => { handlerOutcome = `resolved` @@ -3605,12 +3619,12 @@ describe(`query collection ownership lifecycle`, () => { outcome: `rejected`, }) await vi.waitFor(() => { - expect(collection.utils.lastError).toBe(applicationError) + expect(reportedError(collection)).toBe(applicationError) }) expect({ refetch: handlerOutcome, - applicationErrorRecorded: collection.utils.lastError === applicationError, + applicationErrorRecorded: reportedError(collection) === applicationError, }).toEqual(observeHandlerRefetchBoundary(model)) }) @@ -4215,7 +4229,7 @@ describe(`query collection ownership lifecycle`, () => { } }) - it(`does not reuse an eager initial rejection when clearError applies a valid result`, async () => { + it(`does not reuse an eager initial rejection when a refetch applies a valid result`, async () => { const id = `eager-recovered-result-settlement` const invalidResult = 42 as unknown as Array const recovered = { ...shared, name: `Recovered` } @@ -4252,14 +4266,14 @@ describe(`query collection ownership lifecycle`, () => { result: `invalid-shape`, }) expect(collection.status).toBe(`error`) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBeDefined() expected = advanceResultSettlementModel(expected, { type: `start-refetch`, operation: `recovered`, }) let rejection: unknown - const recovery = collection.utils.clearError().then( + const recovery = collection.utils.refetch({ throwOnError: true }).then( () => ({ settled: true, outcome: `fulfilled` as const, @@ -4280,12 +4294,10 @@ describe(`query collection ownership lifecycle`, () => { expectPublicRefetchObservation(expected, `recovered`, await recovery) expect(rejection).toBeUndefined() - // With the new design, an initial error is final for its sync attempt. - // clearError() triggers a refetch, but status stays error. The successful - // data IS accessible (see below), but status changes only with cleanup+preload. + // An initial error is final for its sync run. The refetch applies its + // rows, but the Collection keeps its initial error until a restart. expect(collection.status).toBe(`error`) - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.errorCount).toBe(0) + expect(reportedError(collection)).toBeDefined() expect(collection.get(shared.id)?.name).toBe(recovered.name) }) @@ -4376,21 +4388,22 @@ describe(`query collection ownership lifecycle`, () => { ).rejects.toBe(previousError) } - const observeError = () => ({ - lastError: collection.utils.lastError, - isError: collection.utils.isError, - errorCount: collection.utils.errorCount, - }) - // An independent three-state model: pending retains the previous - // failure; a failed retry advances the consecutive count; success resets. - const expected = { - pending: { lastError: previousError, isError: true, errorCount: 1 }, - failure: { lastError: retryError, isError: true, errorCount: 2 }, - success: { lastError: undefined, isError: false, errorCount: 0 }, - } + const observeError = () => reportedError(collection) + // An independent model. An initial error is final for its sync run, so + // every later state reports it. After readiness, a pending retry keeps + // the previous health error, a failed retry reports its own, and a + // successful retry clears it. + const expected = + priorFailure === `initial` + ? { + pending: previousError, + failure: previousError, + success: previousError, + } + : { pending: previousError, failure: retryError, success: undefined } expect(observeError()).toEqual(expected.pending) - const retry = collection.utils.clearError().then( + const retry = collection.utils.refetch({ throwOnError: true }).then( () => ({ outcome: `fulfilled` as const }), (error: unknown) => ({ outcome: `rejected` as const, error }), ) @@ -4462,32 +4475,16 @@ describe(`query collection ownership lifecycle`, () => { | undefined try { await expect(collection.stateWhenReady()).rejects.toBe(applicationError) - expect({ - lastError: collection.utils.lastError, - isError: collection.utils.isError, - errorCount: collection.utils.errorCount, - }).toEqual({ - lastError: applicationError, - isError: true, - errorCount: 1, - }) + expect(reportedError(collection)).toBe(applicationError) - retry = collection.utils.clearError().then( + retry = collection.utils.refetch({ throwOnError: true }).then( () => ({ outcome: `fulfilled` as const }), (error: unknown) => ({ outcome: `rejected` as const, error }), ) await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2)) // Query still holds its old successful result, despite the failed // Collection application. A fetch-start success notification is not recovery. - expect({ - lastError: collection.utils.lastError, - isError: collection.utils.isError, - errorCount: collection.utils.errorCount, - }).toEqual({ - lastError: applicationError, - isError: true, - errorCount: 1, - }) + expect(reportedError(collection)).toBe(applicationError) if (retryResult === `failure`) { heldResult.reject(retryError) @@ -4495,14 +4492,14 @@ describe(`query collection ownership lifecycle`, () => { outcome: `rejected`, error: retryError, }) - expect(collection.utils.lastError).toBe(retryError) - expect(collection.utils.errorCount).toBe(2) } else { heldResult.resolve([shared]) expect(await retry).toEqual({ outcome: `fulfilled` }) - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.errorCount).toBe(0) } + // The failed application was the initial load, so its error is final + // for this sync run whatever the retry does. + expect(reportedError(collection)).toBe(applicationError) + expect(collection.status).toBe(`error`) } finally { heldResult.resolve([shared]) await retry @@ -4545,23 +4542,21 @@ describe(`query collection ownership lifecycle`, () => { await expect( Promise.resolve(initialLoad === true ? undefined : initialLoad), ).rejects.toBe(previousError) - expect(collection.utils.lastError).toBe(previousError) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(previousError) + expect(reportedError(collection)).toBeDefined() - const retry = collection.utils.clearError() + const retry = collection.utils.refetch({ throwOnError: true }) await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(2) expect(scanPersistedRows).toHaveBeenCalledOnce() }) // Query has fetched successfully, but Collection application is held. - expect(collection.utils.lastError).toBe(previousError) - expect(collection.utils.isError).toBe(true) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(previousError) + expect(reportedError(collection)).toBeDefined() persistedScan.resolve([]) await retry - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.errorCount).toBe(0) + expect(reportedError(collection)).toBeUndefined() } finally { firstResult.resolve([]) persistedScan.resolve([]) @@ -4624,11 +4619,11 @@ describe(`query collection ownership lifecycle`, () => { await expect( Promise.resolve(initialLoad === true ? undefined : initialLoad), ).rejects.toBe(previousError) - expect(collection.utils.lastError).toBe(previousError) + expect(reportedError(collection)).toBe(previousError) const firstRetry = ( retryMode === `clearError` - ? collection.utils.clearError() + ? collection.utils.refetch({ throwOnError: true }) : collection.utils.refetch({ throwOnError: true }) ).then( () => ({ outcome: `fulfilled` as const }), @@ -4638,19 +4633,19 @@ describe(`query collection ownership lifecycle`, () => { expect(queryFn).toHaveBeenCalledTimes(2) expect(scanPersistedRows).toHaveBeenCalledOnce() }) - expect(collection.utils.lastError).toBe(previousError) + expect(reportedError(collection)).toBe(previousError) const secondRetry = collection.utils.refetch({ throwOnError: true }) await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(3)) laterResult.reject(newerError) await expect(secondRetry).rejects.toBe(newerError) - expect(collection.utils.lastError).toBe(newerError) - expect(collection.utils.errorCount).toBe(2) + expect(reportedError(collection)).toBe(newerError) + expect(reportedError(collection)).toBeDefined() persistedScan.resolve([]) expect(await firstRetry).toEqual({ outcome: `fulfilled` }) - expect(collection.utils.lastError).toBe(newerError) - expect(collection.utils.errorCount).toBe(2) + expect(reportedError(collection)).toBe(newerError) + expect(reportedError(collection)).toBeDefined() } finally { firstResult.resolve([]) laterResult.resolve([]) @@ -4704,29 +4699,28 @@ describe(`query collection ownership lifecycle`, () => { await expect( collection.utils.refetch({ throwOnError: true }), ).rejects.toBe(previousError) - expect(collection.utils.errorCount).toBe(1) - const retry = collection.utils.clearError().then( + expect(reportedError(collection)).toBeDefined() + const retry = collection.utils.refetch({ throwOnError: true }).then( () => ({ outcome: `fulfilled` as const }), (error: unknown) => ({ outcome: `rejected` as const, error }), ) try { await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(4)) // Query retains its old error while its first retry attempt has failed. - expect(collection.utils.lastError).toBe(previousError) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(previousError) + expect(reportedError(collection)).toBeDefined() if (outcome === `success`) { heldResult.resolve([shared]) expect(await retry).toEqual({ outcome: `fulfilled` }) - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.errorCount).toBe(0) + expect(reportedError(collection)).toBeUndefined() } else { heldResult.reject(finalError) expect(await retry).toEqual({ outcome: `rejected`, error: finalError, }) - expect(collection.utils.lastError).toBe(finalError) - expect(collection.utils.errorCount).toBe(2) + expect(reportedError(collection)).toBe(finalError) + expect(reportedError(collection)).toBeDefined() } } finally { heldResult.resolve([shared]) @@ -5048,7 +5042,7 @@ describe(`query collection ownership lifecycle`, () => { }) expect(collection.status).toBe(`idle`) - expect(collection.utils.isError).toBe(false) + expect(reportedError(collection)).toBeUndefined() expect(collection.status).toBe(`idle`) collection.utils.writeUpsert({ @@ -5715,7 +5709,7 @@ describe(`query collection ownership lifecycle`, () => { expect(queryFn).toHaveBeenCalledOnce() expect(replaced).toBe(true) - expect(collection.utils.lastError).toBe(applicationError) + expect(reportedError(collection)).toBe(applicationError) expect(itemIds(collection.toArray)).toEqual([initial.id]) }) @@ -5773,7 +5767,7 @@ describe(`query collection ownership lifecycle`, () => { failNextCommit = true queryClient.setQueryData(detailKey, [detail, list]) - await vi.waitFor(() => expect(collection.utils.lastError).toBe(commitError)) + await vi.waitFor(() => expect(reportedError(collection)).toBe(commitError)) expect(itemIds(collection.toArray)).toEqual([detail.id]) await collection._sync.loadSubset(listSubset) @@ -6539,7 +6533,7 @@ describe(`query collection ownership lifecycle`, () => { cleanups.push(async () => subscription.unsubscribe()) queryClient.setQueryData(queryKey, [outer]) - await vi.waitFor(() => expect(collection.utils.errorCount).toBe(1)) + await vi.waitFor(() => expect(reportedError(collection)).toBeDefined()) await pendingWaiter await expect(Promise.resolve(collection._sync.loadSubset({}))).rejects.toBe( applicationError, @@ -9186,9 +9180,8 @@ describe(`query collection ownership lifecycle`, () => { await expect(ownerB._sync.loadSubset({})).rejects.toBe(failure) expect(queryFn).toHaveBeenCalledTimes(2) expect(ownerB.has(`x`)).toBe(false) - expect(ownerB.utils.lastError).toBe(failure) - expect(ownerB.utils.isError).toBe(true) - expect(ownerB.utils.errorCount).toBe(1) + expect(reportedError(ownerB)).toBe(failure) + expect(reportedError(ownerB)).toBeDefined() expect(ownerA.get(`x`)?.name).toBe(`Manual`) }, ) @@ -11341,7 +11334,7 @@ describe(`query collection ownership lifecycle`, () => { .find({ queryKey: [id], exact: true })! if (cut === `error`) { await collection.utils.refetch() - expect(collection.utils.lastError).toBe(fetchError) + expect(reportedError(collection)).toBe(fetchError) } else { if (cut === `invalidation`) { await queryClient.invalidateQueries({ @@ -11360,8 +11353,8 @@ describe(`query collection ownership lifecycle`, () => { expect(collection.get(`x`)?.name).toBe(`after`) expect(query.state.dataUpdatedAt).toBe(updatedAt) if (cut === `error`) { - expect(collection.utils.lastError).toBe(fetchError) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(fetchError) + expect(reportedError(collection)).toBeDefined() } else if (cut === `invalidation`) { expect(query.state.isInvalidated).toBe(true) } else { diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 1dbd38b2df..ad1e234ab3 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -43,6 +43,20 @@ import type { } from '@tanstack/db' import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' +/** + * The Query failure a Collection reports: its initial error while it is in + * `error`, or the cause of its health error after readiness. + */ +function reportedError(collection: { + status: string + health: { error: { cause: unknown } | null } + _lifecycle: { getSyncError: () => unknown } +}): unknown { + return collection.status === `error` + ? collection._lifecycle.getSyncError() + : collection.health.error?.cause +} + interface TestItem { id: string name: string @@ -577,7 +591,7 @@ describe(`QueryCollection`, () => { try { await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(1) - expect(collection.utils.lastError).toBe(error) + expect(reportedError(collection)).toBe(error) }) expect(stripVirtualProps(collection.get(initialRow.id))).toEqual( initialRow, @@ -3229,7 +3243,7 @@ describe(`QueryCollection`, () => { await vi.waitFor(() => { expect(queryFn).toHaveBeenCalledTimes(2) - expect(collection.utils.lastError).toBe(refetchError) + expect(reportedError(collection)).toBe(refetchError) }) expect(collection.size).toBe(1) expect(stripVirtualProps(collection.get(`1`))).toEqual({ @@ -5468,7 +5482,7 @@ describe(`QueryCollection`, () => { ]) }) - it(`should use exact targeting when clearError() refetches to avoid unintended cascading`, async () => { + it(`should use exact targeting when a throwing refetch avoids unintended cascading`, async () => { const queryKey1 = [`todos`, `project-1`] const queryKey2 = [`todos`, `project-2`] @@ -5506,14 +5520,14 @@ describe(`QueryCollection`, () => { const collection2 = createCollection(options2) await vi.waitFor(() => { - expect(collection1.utils.isError).toBe(true) + expect(reportedError(collection1)).toBeDefined() expect(collection2.status).toBe(`ready`) }) queryFn1.mockClear() queryFn2.mockClear() - await collection1.utils.clearError() + await collection1.utils.refetch({ throwOnError: true }) await vi.waitFor(() => { expect(queryFn1).toHaveBeenCalledTimes(1) @@ -5543,7 +5557,7 @@ describe(`QueryCollection`, () => { ) await vi.waitFor(() => { - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() }) await expect( @@ -5728,8 +5742,8 @@ describe(`QueryCollection`, () => { await expect(collection.preload()).rejects.toBe(applicationError) expect(collection.status).toBe(`error`) - expect(collection.utils.lastError).toBe(applicationError) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(applicationError) + expect(reportedError(collection)).toBeDefined() expect(collection.size).toBe(0) await collection.cleanup() @@ -5792,38 +5806,32 @@ describe(`QueryCollection`, () => { // Wait for initial success - no errors await vi.waitFor(() => { expect(collection.status).toBe(`ready`) - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.isError).toBe(false) - expect(collection.utils.errorCount).toBe(0) + expect(reportedError(collection)).toBeUndefined() }) // First error - count increments await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError).toBe(errors[0]) - expect(collection.utils.errorCount).toBe(1) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBe(errors[0]) + expect(reportedError(collection)).toBeDefined() }) // Second error - count increments again await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError).toBe(errors[1]) - expect(collection.utils.errorCount).toBe(2) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBe(errors[1]) + expect(reportedError(collection)).toBeDefined() }) // Successful refetch resets error state await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.isError).toBe(false) - expect(collection.utils.errorCount).toBe(0) + expect(reportedError(collection)).toBeUndefined() expect(stripVirtualProps(collection.get(`1`))).toEqual(updatedData[0]) }) }) - it(`should support manual error recovery with clearError`, async () => { + it(`keeps an initial error after a refetch applies rows`, async () => { const recoveryData = [{ id: `1`, name: `Item 1` }] const testError = new Error(`Test error`) @@ -5840,26 +5848,23 @@ describe(`QueryCollection`, () => { // Wait for initial error await vi.waitFor(() => { - expect(collection.utils.isError).toBe(true) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBeDefined() }) - // Manual error clearing triggers refetch - await collection.utils.clearError() - - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.isError).toBe(false) - expect(collection.utils.errorCount).toBe(0) + // A refetch applies rows, but the initial error is final for the sync run. + await collection.utils.refetch({ throwOnError: true }) + expect(collection.status).toBe(`error`) + expect(reportedError(collection)).toBe(testError) await vi.waitFor(() => { expect(stripVirtualProps(collection.get(`1`))).toEqual(recoveryData[0]) }) // Refetch on rejection should throw an error - await expect(collection.utils.clearError()).rejects.toThrow(testError) - expect(collection.utils.lastError).toBe(testError) - expect(collection.utils.isError).toBe(true) - expect(collection.utils.errorCount).toBe(1) + await expect( + collection.utils.refetch({ throwOnError: true }), + ).rejects.toThrow(testError) + expect(reportedError(collection)).toBe(testError) }) it(`should maintain collection functionality despite errors and persist error state`, async () => { @@ -5887,8 +5892,7 @@ describe(`QueryCollection`, () => { // Cause error await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.errorCount).toBe(1) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() }) // Collection operations still work with cached data @@ -5904,25 +5908,23 @@ describe(`QueryCollection`, () => { await flushPromises() - expect(collection.utils.lastError).toBe(testError) - expect(collection.utils.isError).toBe(true) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(testError) + expect(reportedError(collection)).toBeDefined() // Another failed fetch increments the recorded error count. await collection.utils.refetch() - await vi.waitFor(() => expect(collection.utils.isError).toBe(true)) + await vi.waitFor(() => expect(reportedError(collection)).toBeDefined()) - const originalError = collection.utils.lastError - const originalErrorCount = collection.utils.errorCount + const originalError = reportedError(collection) // Read-only operations don't affect error state expect(collection.has(`1`)).toBe(true) const changeHandler = vi.fn() const subscription = collection.subscribeChanges(changeHandler) - expect(collection.utils.lastError).toBe(originalError) - expect(collection.utils.isError).toBe(true) - expect(collection.utils.errorCount).toBe(originalErrorCount) + expect(reportedError(collection)).toBe(originalError) + expect(reportedError(collection)).toBeDefined() + expect(reportedError(collection)).toBe(originalError) subscription.unsubscribe() }) @@ -5962,19 +5964,19 @@ describe(`QueryCollection`, () => { // No initial snapshot exists, so the collection reports an error. await vi.waitFor(() => { expect(collection.status).toBe(`error`) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() }) // Verify custom error is accessible with all its properties - const lastError = collection.utils.lastError + const lastError = reportedError(collection) as typeof customError expect(lastError).toBe(customError) - expect(lastError?.code).toBe(`NETWORK_ERROR`) - expect(lastError?.message).toBe(`Failed to fetch data`) - expect(lastError?.details?.retryAfter).toBe(5000) - expect(collection.utils.errorCount).toBe(1) + expect(lastError.code).toBe(`NETWORK_ERROR`) + expect(lastError.message).toBe(`Failed to fetch data`) + expect(lastError.details?.retryAfter).toBe(5000) + expect(reportedError(collection)).toBeDefined() }) - it(`should persist error state after collection cleanup`, async () => { + it(`clears the reported error when the collection is cleaned up`, async () => { const testError = new Error(`Persistent error`) // Start with error immediately @@ -5988,24 +5990,22 @@ describe(`QueryCollection`, () => { // No initial snapshot exists, so the collection reports an error. await vi.waitFor(() => { expect(collection.status).toBe(`error`) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() }) // Verify error state before cleanup - expect(collection.utils.lastError).toBe(testError) - expect(collection.utils.errorCount).toBe(1) + expect(reportedError(collection)).toBe(testError) + expect(reportedError(collection)).toBeDefined() // Cleanup collection await collection.cleanup() expect(collection.status).toBe(`cleaned-up`) - // Error state should persist after cleanup - expect(collection.utils.isError).toBe(true) - expect(collection.utils.lastError).toBe(testError) - expect(collection.utils.errorCount).toBe(1) + // Cleanup ends the sync run, and with it the initial error. + expect(reportedError(collection)).toBeUndefined() }) - it(`should increment errorCount only after final failure when using Query retries`, async () => { + it(`should report an error only after the final failure when using Query retries`, async () => { const testError = new Error(`Retry test error`) const retryCount = 2 const totalAttempts = retryCount + 1 @@ -6033,16 +6033,16 @@ describe(`QueryCollection`, () => { () => { expect(collection.status).toBe(`error`) expect(queryFn).toHaveBeenCalledTimes(totalAttempts) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() }, { timeout: 2000 }, ) // Error count should only increment once after all retries are exhausted // This ensures we track "consecutive post-retry failures," not per-attempt failures - expect(collection.utils.errorCount).toBe(1) - expect(collection.utils.lastError).toBe(testError) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() + expect(reportedError(collection)).toBe(testError) + expect(reportedError(collection)).toBeDefined() // Reset attempt counter for second test queryFn.mockClear() @@ -6059,9 +6059,9 @@ describe(`QueryCollection`, () => { ) // Error count should now be 2 (two post-retry failures) - expect(collection.utils.errorCount).toBe(2) - expect(collection.utils.lastError).toBe(testError) - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() + expect(reportedError(collection)).toBe(testError) + expect(reportedError(collection)).toBeDefined() }) }) @@ -10863,7 +10863,7 @@ describe(`QueryCollection`, () => { await failedRefetchStarted.promise failedRefetch.reject(failure) await refetchErrored.promise - expect(collection.utils.lastError).toBe(failure) + expect(reportedError(collection)).toBe(failure) await first.cleanup() expect(collection.size).toBe(0) From 8a95d3bc0e80390f6fa2a46142df5cf328b97bbd Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 14 Oct 2026 21:54:19 -0700 Subject: [PATCH 051/106] docs(oracle-coverage): describe the simplified health grammar and witnesses Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 8bc6545238..2bc4970fa4 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection health and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the initial gate, health problems with serials, and crashes derives each live query's gate and its expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `reportFinal`, `healthy`, `crash`, and restart; the check compares status, rows, the projected tree, node identity, and `status:change`/`health:change` ordering after every step. Request and Effect lanes cover request errors and Effect disposal on an initial error or crash. The shared framework conformance scenario `health-after-readiness` judges the same law through every hook. Adapter receivers: Electric's process grammar and partition laws, TrailBase's stream-end lane, PowerSync's rebuild outage, query-db's `utils.lastError` mirror, and the persisted durability lanes. Open: on-demand sources that fail their own initial sync after a live query is ready are generated only in the Effect lane; a Suspense render after readiness has no direct framework witness; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection health and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the initial gate, health problems with serials, and crashes derives each live query's gate and its expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, and restart; the check compares status, rows, the projected tree, node identity, and `status:change`/`health:change` ordering after every step. Request and Effect lanes cover request errors and Effect disposal on an initial error or crash. The infinite-query oracle keeps the window's pages and `ready` status after a failed window request. The shared framework conformance scenario `health-after-readiness` judges the same law through every hook. Adapter receivers: Electric's process grammar and partition laws, TrailBase's stream-end lane, PowerSync's rebuild outage, query-db's failed-refetch witness, and the persisted durability lanes. Open: on-demand sources that fail their own initial sync after a live query is ready are generated only in the Effect lane; a Suspense render after readiness has no direct framework witness; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The virtual-clock owner checks queue order, bounded admission, cleanup draining in FIFO/LIFO order at the configured wait, admitted writes after separate Collection cleanup, post-cleanup queue rejection with a disposal reason, pending debounce drain at the last call's quiet edge, pending trailing throttle drain at its regular edge, debounce and throttle edge options including omitted options, immediate rejection of dropped leading-only calls and both-disabled calls, caller-option immutability, and settlement. New debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | From 9dbcaf80ac2f0a12eb63406efa1ce84fc298b782 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 15 Oct 2026 07:01:14 -0700 Subject: [PATCH 052/106] test(conformance): require every binding to expose the exact error value Issue #672 asks that each framework binding expose the exact value a sync adapter failed with. The error-status scenario now checks the initial error's cause by identity, and the health scenario checks each health error's cause with toBe instead of comparing messages. A mutant that copies the error into a new Error fails both scenarios. Co-authored-by: Isaac --- packages/db/tests/conformance/suite.ts | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index ee8c182c10..b4d302e08c 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1069,6 +1069,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { final: true, message: source.expectedError.message, }) + // The binding exposes the exact value the sync adapter gave up with. + expect(result.errors?.error?.cause).toBe(source.expectedError) } h.unmount() }, @@ -1091,12 +1093,12 @@ export function runSuite(rawDriver: LiveQueryDriver) { `health-after-readiness`, `health reports after readiness appear only in the error tree`, async () => { - type Problem = { final: boolean; message: string; serial: number } + type Problem = { final: boolean; cause: Error; serial: number } let problem: Problem | null = null let serial = 0 - const report = (message: string, final: boolean) => { + const report = (cause: Error, final: boolean) => { if (problem?.final) return - problem = { final, message, serial: ++serial } + problem = { final, cause, serial: ++serial } } const healthy = () => { if (problem && !problem.final) problem = null @@ -1148,10 +1150,14 @@ export function runSuite(rawDriver: LiveQueryDriver) { error: problem && { phase: `sync`, final: problem.final, - message: problem.message, + message: problem.cause.message, }, }, }) + // The binding exposes the exact value the sync adapter reported. + expect(node?.error?.cause, `${step}: error cause`).toBe( + problem?.cause, + ) const current = { tree, serial: problem?.serial ?? null } if (previous) { if (previous.serial === current.serial) @@ -1179,8 +1185,9 @@ export function runSuite(rawDriver: LiveQueryDriver) { [ `a health error that is not final`, () => { - source.reportError(new Error(`stream stalled`)) - report(`stream stalled`, false) + const stalled = new Error(`stream stalled`) + source.reportError(stalled) + report(stalled, false) }, ], [ @@ -1193,8 +1200,9 @@ export function runSuite(rawDriver: LiveQueryDriver) { [ `a final health error`, () => { - source.markError(new Error(`stream ended`)) - report(`stream ended`, true) + const ended = new Error(`stream ended`) + source.markError(ended) + report(ended, true) }, ], [ @@ -1207,8 +1215,9 @@ export function runSuite(rawDriver: LiveQueryDriver) { [ `a later report cannot replace a final health error`, () => { - source.reportError(new Error(`late`)) - report(`late`, false) + const late = new Error(`late`) + source.reportError(late) + report(late, false) }, ], ] From dd521115927d7fd2dedd7c6d910bf83d3e719bd6 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 15 Oct 2026 07:34:37 -0700 Subject: [PATCH 053/106] fix: close review findings in the health channel - Persistence: an upstream markError after Collection readiness is a final health error and the sync run continues, so it no longer takes the first-error latch that let a later persistence failure skip its crash. - Request errors: a node shows the latest failure still failing; settling one request falls back to an older failure instead of showing a settled one. - query-db: Collection health stays in error while any observed Query is failing, and a re-notified error is not reported again. - Effects: a source status change reaches onHealthChange once, and an unchanged tree is not reported again. - Error tree: a source's node is cached under the query that reads it, so a long-lived source does not keep a node for every query built over it. - Includes: only a change in a child Collection's error state reaches the parent, so healthy children that start no longer rebuild the tree. Witnesses: the persisted unload lane gains a "sync adapter gave up first" cell; the health oracle gains a generated request-error bookkeeping property, a report-after-gate Effect lane that requires one report per change, and a no-relay check for healthy include children; the load-subset oracle gains a two-Query health witness. Each fails on the previous head. Co-authored-by: Isaac --- .../src/persisted.ts | 8 +- .../tests/persisted-oracle.test.ts | 164 ++++++++++-------- packages/db/src/collection/error-tree.ts | 7 +- packages/db/src/collection/lifecycle.ts | 16 +- packages/db/src/query/effect.ts | 13 +- .../src/query/live/bucket-facade-adapter.ts | 10 +- packages/db/src/query/pooled-live-query.ts | 2 +- packages/db/tests/oracle-config.ts | 1 + .../db/tests/source-health-oracle.test.ts | 93 +++++++++- packages/query-db-collection/src/query.ts | 36 ++-- .../load-subset-lifecycle-oracle.test.ts | 61 +++++++ 11 files changed, 300 insertions(+), 111 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index a07863607b..ca318b168b 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -1879,8 +1879,12 @@ class PersistedCollectionRuntime< if (this.syncErrorReported) return this.reportedSyncError if (!markError) return error - this.syncErrorReported = true - this.reportedSyncError = error + // After readiness the sync adapter's markError is a final health error + // and the run continues, so only a failure that ends the run latches. + if (origin === `persistence` || this.collection?.status !== `ready`) { + this.syncErrorReported = true + this.reportedSyncError = error + } try { if (origin === `sync adapter`) markError(error) else this.collection?._lifecycle.crash(error) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 7002185e57..68e550fd19 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -6131,83 +6131,101 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } }) - it(`reports one fail-stop lifecycle error when a single-process async unload rejects`, async () => { - const unloadError = new Error(`remote owner unload failed`) - let unloadThenCalls = 0 - const rejectingThenable = { - then: ( - _resolve: (value?: void) => void, - reject: (error: unknown) => void, - ) => { - unloadThenCalls++ - queueMicrotask(() => reject(unloadError)) - }, - } - const sourceUnloadMock = vi.fn((options: LoadSubsetOptions) => - options.limit === 1 ? rejectingThenable : undefined, - ) - const sourceUnload = sourceUnloadMock as unknown as ( - options: LoadSubsetOptions, - ) => void - const collection = createCollection( - persistedCollectionOptions({ - id: `owner-unload-error`, - getKey: (todo) => todo.id, - syncMode: `on-demand`, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: () => true, - unloadSubset: sourceUnload, - } - }, - }, - persistence: { - adapter: createRecordingAdapter(), + // A sync adapter that gives up after Collection readiness reports a final + // health error, and the sync run continues. A later persistence failure + // must still crash the Collection, so the first-error latch may hold only + // failures that end the sync run. + it.each([false, true])( + `reports one fail-stop lifecycle error when a single-process async unload rejects (sync adapter gave up first: %s)`, + async (gaveUpFirst) => { + const unloadError = new Error(`remote owner unload failed`) + const gaveUp = new Error(`sync adapter gave up`) + let giveUp: ((error: unknown) => void) | undefined + let unloadThenCalls = 0 + const rejectingThenable = { + then: ( + _resolve: (value?: void) => void, + reject: (error: unknown) => void, + ) => { + unloadThenCalls++ + queueMicrotask(() => reject(unloadError)) }, - }), - ) - const failing: LoadSubsetOptions = { limit: 1 } - const sibling: LoadSubsetOptions = { limit: 2 } - const crash = vi.spyOn(collection._lifecycle, `crash`) - const unhandled: Array = [] - const captureUnhandled = (error: unknown) => unhandled.push(error) - process.on(`unhandledRejection`, captureUnhandled) + } + const sourceUnloadMock = vi.fn((options: LoadSubsetOptions) => + options.limit === 1 ? rejectingThenable : undefined, + ) + const sourceUnload = sourceUnloadMock as unknown as ( + options: LoadSubsetOptions, + ) => void + const collection = createCollection( + persistedCollectionOptions({ + id: `owner-unload-error`, + getKey: (todo) => todo.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady, markError }) => { + markReady() + giveUp = markError + return { + loadSubset: () => true, + unloadSubset: sourceUnload, + } + }, + }, + persistence: { + adapter: createRecordingAdapter(), + }, + }), + ) + const failing: LoadSubsetOptions = { limit: 1 } + const sibling: LoadSubsetOptions = { limit: 2 } + const crash = vi.spyOn(collection._lifecycle, `crash`) + const unhandled: Array = [] + const captureUnhandled = (error: unknown) => unhandled.push(error) + process.on(`unhandledRejection`, captureUnhandled) - try { - collection.startSyncImmediate() - await collection._sync.loadSubset(failing) - await collection._sync.loadSubset(sibling) - collection._sync.unloadSubset(failing) - collection._sync.unloadSubset(sibling) - await flushAsyncWork() - await flushAsyncWork() + try { + collection.startSyncImmediate() + await collection._sync.loadSubset(failing) + await collection._sync.loadSubset(sibling) + if (gaveUpFirst) { + giveUp!(gaveUp) + await flushAsyncWork() + expect(collection.status).toBe(`ready`) + expect(collection.health.error?.cause).toBe(gaveUp) + } + collection._sync.unloadSubset(failing) + collection._sync.unloadSubset(sibling) + await flushAsyncWork() + await flushAsyncWork() - expect({ - status: collection.status, - error: collection._lifecycle.getSyncError(), - unloadThenCalls, - unloadOptions: sourceUnloadMock.mock.calls.map(([options]) => options), - lifecycleErrors: crash.mock.calls.map(([error]) => error), - unhandled, - }).toEqual({ - status: `error`, - error: unloadError, - unloadThenCalls: 1, - unloadOptions: [failing, sibling], - lifecycleErrors: [unloadError], - unhandled: [], - }) + expect({ + status: collection.status, + error: collection._lifecycle.getSyncError(), + unloadThenCalls, + unloadOptions: sourceUnloadMock.mock.calls.map( + ([options]) => options, + ), + lifecycleErrors: crash.mock.calls.map(([error]) => error), + unhandled, + }).toEqual({ + status: `error`, + error: unloadError, + unloadThenCalls: 1, + unloadOptions: [failing, sibling], + lifecycleErrors: [unloadError], + unhandled: [], + }) - await flushAsyncWork() - expect(sourceUnloadMock).toHaveBeenCalledTimes(2) - expect(crash).toHaveBeenCalledTimes(1) - } finally { - process.off(`unhandledRejection`, captureUnhandled) - await collection.cleanup() - } - }) + await flushAsyncWork() + expect(sourceUnloadMock).toHaveBeenCalledTimes(2) + expect(crash).toHaveBeenCalledTimes(1) + } finally { + process.off(`unhandledRejection`, captureUnhandled) + await collection.cleanup() + } + }, + ) it(`surfaces duplicate remote-subset owner registration through the collection lifecycle`, async () => { const coordinator = new SingleProcessCoordinator() diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index 19e0fe89ec..aa24cffdd0 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -31,6 +31,9 @@ export function buildErrorTree( { alias, id, kind, instances }: Omit = { alias: `root`, }, + // The reader that caches this node, so a source's node lives only as long + // as the query that reads it. + owner: object = collection, ): ErrorTreeNode { // A live query's tree shape comes from its query definition. const builder = getCollectionBuilder(collection) @@ -39,9 +42,9 @@ export function buildErrorTree( const error: CollectionErrorInfo | null = lifecycle.gateProblem ?? lifecycle.health.error const children = sources.map((source) => - buildErrorTree(source.collection, source), + buildErrorTree(source.collection, source, collection), ) - return errorTreeNode(collection, id, { + return errorTreeNode(owner, id, { alias, collectionId: collection.id, kind: kind ?? (builder ? `live-query` : `source`), diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index d2d57dd6d9..667729488e 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -609,19 +609,23 @@ export class CollectionLifecycleManager< /** @internal Requests that failed after initial-query readiness, by issuer. */ export class RequestErrors { - private readonly failed = new Set() + // In failure order, so the latest failure still failing is last. + private readonly failed = new Map() problem: CollectionErrorInfo | null = null fail(key: string, error: unknown): void { - this.failed.add(key) + this.failed.delete(key) this.problem = describeProblem(error, `request`, false) + this.failed.set(key, this.problem) } - /** Returns whether the last failed request settled. */ + /** Returns whether the shown problem changed. */ settle(key: string): boolean { - if (!this.failed.delete(key) || this.failed.size > 0) return false - this.problem = null - return true + if (!this.failed.delete(key)) return false + const shown = [...this.failed.values()].at(-1) ?? null + const changed = shown !== this.problem + this.problem = shown + return changed } } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 5008ae0e54..ebce3eedce 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -475,6 +475,7 @@ class EffectPipelineRunner { // a Collection health problem does not dispose the Effect. private reachedReady = false private readonly requests = new RequestErrors() + private reportedTree: ErrorTreeNode | undefined private readonly onHealthChange: ((errors: ErrorTreeNode) => void) | undefined // Scheduler integration @@ -767,8 +768,8 @@ class EffectPipelineRunner { ) { this.initialLoadComplete = true } - const wasReady = this.reachedReady - if (this.latchReady() && wasReady) this.reportHealth() + // health:change follows every status change and reports the tree. + this.latchReady() }) this.unsubscribeCallbacks.add(statusUnsubscribe) this.unsubscribeCallbacks.add( @@ -1095,14 +1096,18 @@ class EffectPipelineRunner { status: this.reachedReady ? `ready` : `loading`, error: this.requests.problem, sources: this.collectionSources.map(({ sourceId, alias, collection }) => - buildErrorTree(collection, { alias, id: sourceId }), + buildErrorTree(collection, { alias, id: sourceId }, this), ), }) } private reportHealth(): void { if (this.disposed || !this.reachedReady || !this.onHealthChange) return - this.onHealthChange(this.errorTree()) + // An unchanged tree keeps its identity; report each change once. + const tree = this.errorTree() + if (tree === this.reportedTree) return + this.reportedTree = tree + this.onHealthChange(tree) } private canCompleteInitialLoad(): boolean { diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index 5e18b5c1b8..df4c658d87 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -466,7 +466,15 @@ export class BucketFacadeAdapter { startSync: true, gcTime: 0, }) - collection.on(`health:change`, this.onInstanceHealthChange) + // The include node counts the children in error, so only a change in + // that state concerns the parent; startup status changes do not. + let inError = false + collection.on(`health:change`, () => { + const next = collection.status === `error` || collection.health.isError + if (next === inError) return + inError = next + this.onInstanceHealthChange() + }) const dispose = collection.cleanup.bind(collection) // Other parent rows can share this facade, so a holder must not end it. collection.cleanup = () => diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index a24f24165e..5285552f4b 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -517,7 +517,7 @@ class PooledLiveQuery { /** The same error tree a live-query Collection over this source exposes. */ get errors(): ErrorTreeNode { - const source = buildErrorTree(this.source, { alias: this.alias }) + const source = buildErrorTree(this.source, { alias: this.alias }, this) // The view shows its source's gate. A terminated partition also ended // this view, for example when its source was cleaned up under it. const error = diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index f87245153c..ce1c5b8e19 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -27,6 +27,7 @@ const staticOracleProperties = [ `sqlite-persistence.request-local-failure-buffered-source`, `trailbase.lifecycle`, `source-health.history`, + `source-health.request-errors`, `trailbase.page-event`, `electric.bound-descriptor-history`, `electric.persisted-tag-history`, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index a217ed5f06..ad3da8ede4 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -109,6 +109,7 @@ import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' import { Query } from '../src/query/builder/index.js' import { eq } from '../src/query/builder/functions.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' +import { RequestErrors } from '../src/collection/lifecycle.js' import { BasicIndex } from '../src/indexes/basic-index.js' import { oraclePropertyOptions } from './oracle-config.js' import { flushPromises } from './utils.js' @@ -999,18 +1000,30 @@ describe(`root request errors`, () => { * on-demand source still has its first load pending. After it, the * source calls `markError()`, which is an initial error for an on-demand * source still in its initial sync and a final health error for an eager - * source after Collection readiness, or it crashes. + * source after Collection readiness, or it crashes, or it reaches Collection + * readiness (on-demand only) and then reports a health error that is not + * final. Every history also checks that one change reaches + * `onHealthChange` once: no two reported trees are the same object. */ describe(`effect health`, () => { type EffectHistory = { skipInitial: boolean syncMode: `eager` | `on-demand` - failure: `before gate` | `markError after gate` | `crash after gate` + failure: + | `before gate` + | `markError after gate` + | `crash after gate` + | `report after gate` } const histories: Array = [true, false].flatMap((skipInitial) => ([`eager`, `on-demand`] as const).flatMap((syncMode) => ( - [`before gate`, `markError after gate`, `crash after gate`] as const + [ + `before gate`, + `markError after gate`, + `crash after gate`, + `report after gate`, + ] as const ).map((failure) => ({ skipInitial, syncMode, failure })), ), ) @@ -1082,6 +1095,21 @@ describe(`effect health`, () => { // Model: only `skipInitial` withholds the initial rows. expect(delivered).toEqual(skipInitial ? [] : [1]) + if (failure === `report after gate`) { + // An on-demand source reaches its own Collection readiness after the + // Effect's: a status change that must reach onHealthChange once. + if (syncMode === `on-demand`) params.markReady() + await flushPromises() + params.reportError(new Error(`stalled`)) + await flushPromises() + expect(effect.disposed).toBe(false) + expect(trees.at(-1)).toMatchObject({ + hasErrors: true, + sources: [{ alias: `row`, error: { phase: `sync`, final: false } }], + }) + expect(new Set(trees).size, `one report per change`).toBe(trees.length) + return + } if (failure === `crash after gate`) { params.collection._lifecycle.crash(new Error(`crashed`)) } else { @@ -1095,6 +1123,7 @@ describe(`effect health`, () => { const ends = failure === `crash after gate` || syncMode === `on-demand` expect(effect.disposed).toBe(ends) expect(sourceErrors).toHaveLength(ends ? 1 : 0) + expect(new Set(trees).size, `one report per change`).toBe(trees.length) if (!ends) { expect(trees.at(-1)).toMatchObject({ kind: `live-query`, @@ -1234,7 +1263,9 @@ describe(`effect request errors`, () => { * it throws on a new row, counts in `inError` and makes the include node * `hasErrors`; the live query emits `health:change` for it and keeps its own * status. Two includes that read one Collection under the same alias are - * two nodes, and each counts only its own children. + * two nodes, and each counts only its own children. A healthy child that + * starts does not change any node's error state, so it emits no + * `health:change`; otherwise every new parent row would rebuild the tree. */ describe(`include instances`, () => { it(`a failing child Collection counts in its own include only`, async () => { @@ -1288,6 +1319,12 @@ describe(`include instances`, () => { const stop = query.on(`health:change`, () => { events++ }) + // New parent rows start healthy child Collections. + B.write({ id: 3, parentId: 3 }) + B.write({ id: 4, parentId: 4 }) + await flushPromises() + expect(events, `healthy children emit no health:change`).toBe(0) + expect(includes()[0]!.instances).toEqual({ total: 4, inError: 0 }) // One child Collection of `items` fails: its index throws on row 9. class FailingIndex extends BasicIndex { override add(key: number, item: any): void { @@ -1305,9 +1342,9 @@ describe(`include instances`, () => { expect(query.status).toBe(`ready`) expect(events, `health:change relay`).toBeGreaterThan(0) const [itemsNode, sameNode] = includes() - expect(itemsNode!.instances).toEqual({ total: 2, inError: 1 }) + expect(itemsNode!.instances).toEqual({ total: 4, inError: 1 }) expect(itemsNode!.hasErrors).toBe(true) - expect(sameNode!.instances).toEqual({ total: 2, inError: 0 }) + expect(sameNode!.instances).toEqual({ total: 4, inError: 0 }) expect(sameNode!.hasErrors).toBe(false) expect(query.errors.hasErrors).toBe(true) } finally { @@ -1318,3 +1355,47 @@ describe(`include instances`, () => { } }) }) + +// --------------------------------------------------------------------------- +// Request error bookkeeping +// --------------------------------------------------------------------------- + +/** + * A live query or Effect can have several failed requests at once, one per + * issuer (a lazy demand or a root source). Its node shows one of them: the + * most recent failure among the requests still failing. When the last one + * settles, the node is healthy again. The model is a list of failing issuers + * in failure order; the production driver is the shared `RequestErrors` + * tracker, and the check compares the shown cause after every step, plus + * whether a settle reported a change. + */ +describe(`request error bookkeeping`, () => { + const key = fc.constantFrom(`a`, `b`, `c`) + const step = fc.oneof( + fc.record({ kind: fc.constant(`fail` as const), key }), + fc.record({ kind: fc.constant(`settle` as const), key }), + ) + fcTest.prop( + [fc.array(step, { maxLength: 12 })], + oraclePropertyOptions(100, `source-health.request-errors`), + )(`a node shows the latest failure still failing`, (steps) => { + const tracker = new RequestErrors() + const failing: Array<{ key: string; cause: Error }> = [] + for (const { kind, key: issuer } of steps) { + const before = failing.at(-1)?.cause ?? null + const index = failing.findIndex((entry) => entry.key === issuer) + if (index !== -1) failing.splice(index, 1) + if (kind === `fail`) { + const cause = new Error(`${issuer} failed`) + failing.push({ key: issuer, cause }) + tracker.fail(issuer, cause) + } else { + const changed = tracker.settle(issuer) + expect(changed, `settle ${issuer} reports a change`).toBe( + (failing.at(-1)?.cause ?? null) !== before, + ) + } + expect(tracker.problem?.cause ?? null).toBe(failing.at(-1)?.cause ?? null) + } + }) +}) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 8b6a58e5b0..8cd245963f 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1150,9 +1150,16 @@ export function queryCollectionOptions( let errorRevision = 0 const failedResultApplications = new Map() // A failed fetch or result application after Collection readiness is a - // health error that is not final, since TanStack Query fetches again. A - // later successful application with no newer failure reports healthy. - const reportRecovered = () => { + // health error that is not final, since TanStack Query fetches again. + // The Collection is healthy again once no observed Query is failing. + const failingQueries = new Set() + const reportFailing = (hashedQueryKey: string, error: unknown) => { + failingQueries.add(hashedQueryKey) + if (collection.status === `ready`) reportError(error) + } + const reportRecovered = (hashedQueryKey: string) => { + if (!failingQueries.delete(hashedQueryKey) || failingQueries.size > 0) + return if (collection.status === `ready`) reportHealthy() } const exceptionalResultSettlements = new WeakMap< @@ -2740,7 +2747,7 @@ export function queryCollectionOptions( failedResultApplications.delete(hashedQueryKey) // Applying cached data during a fetch does not establish recovery. if (!isFetching && applicationErrorRevision === errorRevision) { - reportRecovered() + reportRecovered(hashedQueryKey) } } }, @@ -2752,12 +2759,8 @@ export function queryCollectionOptions( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) - if (collection.status === `ready`) { - reportError(error) - } - if (collection.status === `loading`) { - markError(error) - } + if (collection.status === `loading`) markError(error) + else reportFailing(hashedQueryKey, error) }, ) } @@ -2797,7 +2800,7 @@ export function queryCollectionOptions( resultApplicationControllers.delete(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) if (!result.isFetching && currentErrorRevision === errorRevision) { - reportRecovered() + reportRecovered(hashedQueryKey) } } return @@ -3125,11 +3128,10 @@ export function queryCollectionOptions( // A failure before the first successful snapshot leaves no usable // collection state. Later refetch failures keep the last ready // snapshot available and report health. - if (collection.status === `loading`) { - markError(result.error) - } else if (collection.status === `ready`) { - reportError(result.error) - } + // A re-notified error is the same failure, so it is not reported + // again. + if (collection.status === `loading`) markError(result.error) + else if (isNewError) reportFailing(hashedQueryKey, result.error) } } return handleQueryResult @@ -3346,6 +3348,7 @@ export function queryCollectionOptions( } state.observers.delete(hashedQueryKey) + reportRecovered(hashedQueryKey) if (query?.state.fetchStatus !== `fetching`) fetchStartGenerations.delete(hashedQueryKey) queryToRows.delete(hashedQueryKey) @@ -3416,6 +3419,7 @@ export function queryCollectionOptions( unsubscribes.get(hashedQueryKey)?.() unsubscribes.delete(hashedQueryKey) state.observers.delete(hashedQueryKey) + reportRecovered(hashedQueryKey) // Query Core may keep an empty-cache fetch alive without an observer. // A later lease can reuse that request, so keep its start position. if (observer?.getCurrentQuery().state.fetchStatus !== `fetching`) diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index 2a1d61ac72..c09e6d1a94 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -1237,6 +1237,63 @@ async function expectRefetchFailureIsHealth(): Promise { } } +/** + * Collection health covers every Query the Collection observes. With two + * on-demand subsets, one failing refetch is a health error even when the + * other subset's refetch succeeds; the Collection is healthy again only once + * no Query is failing. The model is the set of failing subsets. + */ +async function expectHealthWaitsForEveryFailingQuery(): Promise { + const queryClient = createQueryClient() + const id = `load-subset-health-keys-${collectionSequence++}` + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const failing = new Set() + const queryFn = vi.fn((context: QueryFunctionContext) => { + const limit = context.meta?.loadSubsetOptions?.limit ?? 0 + return failing.has(limit) + ? Promise.reject(new Error(`subset ${limit} failed`)) + : Promise.resolve([{ id: `row-${limit}` }]) + }) + const collection = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + syncMode: `on-demand`, + retry: false, + }), + ) + try { + collection.startSyncImmediate() + await collection._sync.loadSubset({ limit: 1 }) + await collection._sync.loadSubset({ limit: 2 }) + expect(collection.status).toBe(`ready`) + expect(collection.health.isError).toBe(false) + + failing.add(1) + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.health.isError).toBe(true)) + expect(collection.health.error?.message).toBe(`subset 1 failed`) + + // A subset that starts after the failure and succeeds does not make the + // failing subset healthy. + await collection._sync.loadSubset({ limit: 3 }) + await vi.waitFor(() => expect(collection.has(`row-3`)).toBe(true)) + expect(collection.health.error?.message).toBe(`subset 1 failed`) + + failing.clear() + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.health.isError).toBe(false)) + expect(collection.status).toBe(`ready`) + } finally { + loggedError.mockRestore() + await collection.cleanup() + queryClient.clear() + } +} + async function expectInitialQueryFailureStatus(): Promise { const error = new Error(`initial query failed`) const queryClient = createQueryClient() @@ -2650,6 +2707,10 @@ describe(`loadSubset lifecycle oracle`, () => { await expectReleasedRefetchRejects() }) + it(`keeps Collection health in error while any observed Query fails`, async () => { + await expectHealthWaitsForEveryFailingQuery() + }) + it(`reports a failed refetch after readiness as Collection health`, async () => { await expectRefetchFailureIsHealth() }) From e25e750a53b8427551de81d866bc2b90c5c1fdab Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 15 Oct 2026 07:56:22 -0700 Subject: [PATCH 054/106] docs(changeset): describe crashes and multi-Query health in the release note Co-authored-by: Isaac --- .changeset/source-health-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 3a1d1617b3..d90c2038d4 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -14,7 +14,7 @@ Collections now separate two questions: whether data can be shown, and whether it is still kept up to date. -`status` and `isError` describe the initial load of a sync run. A failure after a Collection is ready no longer moves it to `error`: its rows stay, and the problem appears in the new `collection.health`, the `health:change` event, and the `errors` tree that every live-query hook returns. `errors` has one node per query alias with each node's status and error details, keeps its object identity while unchanged, and is plain data apart from each error's `cause`. Effects receive the same tree through `onHealthChange`. +`status` and `isError` say whether data can be shown: they describe the initial load of a sync run, or a crash that needs a restart. Any other failure after a Collection is ready no longer moves it to `error`: its rows stay, and the problem appears in the new `collection.health`, the `health:change` event, and the `errors` tree that every live-query hook returns. `errors` has one node per query alias with each node's status and error details, keeps its object identity while unchanged, and is plain data apart from each error's `cause`. Effects receive the same tree through `onHealthChange`. Breaking changes and migration: @@ -22,6 +22,6 @@ Breaking changes and migration: - After a Collection is ready, sync failures no longer set `status` to `error` or `isError` to `true`. To show a "data may be out of date" message, read `errors.hasErrors` from the hook, or `collection.health`. - A Collection that can no longer serve reads or accept writes crashes: a closed IndexedDB connection or a failed local SQLite persistence write moves it to `error` even after it was ready. Every live query and Effect over a Collection in `error` now fails too, whether the cause is an initial error or a crash. - Adapter authors: call `markError(error)` when you give up. Before the first `markReady()` it fails the sync run; after it, it is a final health error and the rows stay. While you keep trying after readiness, call `reportError(error)`, then `reportHealthy()` when sync works again. `markReady()` after `markError()` in the same sync run now throws. -- Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db reports a failed refetch or result application after readiness as a health error that clears on the next successful result. +- Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db reports a failed refetch or result application after readiness as a health error that clears once every observed Query succeeds again. - query-db removes `utils.lastError`, `utils.isError`, `utils.errorCount`, and `utils.clearError()`. Read `collection.health` or a live query's `errors` tree instead, and retry with `utils.refetch({ throwOnError: true })`. - Infinite-query hooks (`useLiveInfiniteQuery` in React, Vue, and Svelte) remove `error`. A failed page load no longer sets `status` to `error`; it appears as a request error in the returned `errors` tree, and `fetchNextPage()` still resolves. From 020a676526044929292d4642c14df7af5f98c4e9 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 15 Oct 2026 08:04:20 -0700 Subject: [PATCH 055/106] chore(db): refresh the private-member mangle cache The health-channel work removed four private members and added others. Co-authored-by: Isaac --- packages/db/mangle-cache.json | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 0673dd2f48..842fb9f93d 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -99,7 +99,6 @@ "updateCachedEntries": "bF", "drainingNoInputContinuations": "bG", "retiredEntries": "bH", - "getComputedHasNextPage": "bI", "checkTruncateReplayComplete": "bJ", "liveQueryResources": "bK", "markLiveResultAuthoritative": "bL", @@ -128,7 +127,6 @@ "setReadyIfIdle": "b9", "cachedCollectionRevision": "ca", "collectionRestartCleanup": "cb", - "erroredSourceIds": "cc", "pendingHydration": "cd", "persistedSources": "ce", "removeFromBucket": "cf", @@ -200,7 +198,6 @@ "lastBoundary": "dl", "microtaskScheduled": "dm", "resolveValue": "dn", - "syncError": "dp", "windowFailed": "dq", "cleaningUp": "dr", "resolvedValues": "ds", @@ -280,7 +277,6 @@ "compiledIndexEvaluator": "eK", "nextLiveQueryTimestamp": "eL", "restartDetachedDemands": "eM", - "trackAttachmentFailure": "eN", "warnedPlans": "eO", "abandonTruncateReplay": "eP", "captureEntries": "eQ", @@ -403,5 +399,23 @@ "copyRows": "gD", "restore": "gE", "attach": "gF", - "sortedExactValues": "gG" + "sortedExactValues": "gG", + "sourceProblem": "bI", + "reachedReady": "cc", + "clearFailedRequest": "dp", + "initialProblem": "eN", + "requestErrors": "gH", + "recordSourceProblem": "gI", + "reportFailedRequest": "gJ", + "assertReportable": "gK", + "includeSourceIds": "gL", + "reportHealth": "gM", + "onInstanceHealthChange": "gN", + "latchReady": "gO", + "bucketFacades": "gP", + "reportedTree": "gQ", + "settleLoad": "gR", + "endedProblem": "gS", + "errorTree": "gT", + "failLoad": "gU" } From 5873ad0b6c0fa08475743613e785dc65d108b9e7 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 15 Oct 2026 08:18:47 -0700 Subject: [PATCH 056/106] test(indexed-db): expect a final health error when an active receiver read aborts After Collection readiness, a receiver that misses a peer change gives up as a final health error and keeps its rows and status, as the unit cross-tab-boundary oracle already expects. The browser oracle still expected status error. Its harness now exposes Collection health. Co-authored-by: Isaac --- packages/db/e2e/indexed-db/browser.ts | 4 ++++ packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/db/e2e/indexed-db/browser.ts b/packages/db/e2e/indexed-db/browser.ts index e5e1d07c7d..91d54a5ea9 100644 --- a/packages/db/e2e/indexed-db/browser.ts +++ b/packages/db/e2e/indexed-db/browser.ts @@ -263,6 +263,10 @@ async function setup() { tab, rows: snapshot(collection.values()), status: collection.status, + health: collection.health.error && { + phase: collection.health.error.phase, + final: collection.health.error.final, + }, callbacks: { ...callbacks }, reads: [...reads], writes: [...nativeWrites], diff --git a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts index 3993d3ba38..0b6cc7a52e 100644 --- a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts @@ -569,7 +569,12 @@ for (const obsolete of [false, true]) ) const state = await observe(receiver) expect(state.reads).toContain('abort') - expect(state.status).toBe(obsolete ? 'ready' : 'error') + // After Collection readiness, an active receiver that misses a peer + // change gives up as a final health error; it keeps its rows and status. + expect(state.status).toBe('ready') + expect(state.health).toEqual( + obsolete ? null : { phase: 'sync', final: true }, + ) if (obsolete) assertStatuses( state.statuses, From b1adea42004e6982e8a557b0689bf0c09f6a92b8 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 15 Oct 2026 08:53:40 -0700 Subject: [PATCH 057/106] fix: close CodeRabbit findings in the health channel - Includes: retiring a child Collection that is in error relays a parent health:change, so the include node's count does not stay stale. The include-instances lane gains a retirement witness that fails on the previous head. - Effects: a successful ordered load settles the source's request error, as a subset load result already did. - Docs: the plan describes the shipped adapter API, the health candidate and user-docs draft say they are superseded, and the Effect guide points to onHealthChange for problems after the initial load. - Electric oracle: the process-grammar model resets its readiness-terminal flag with the terminal flag. Co-authored-by: Isaac --- .../HEALTH-CANDIDATE.md | 5 +++-- .../designs/source-failure-and-health/PLAN.md | 7 +++---- .../USER-DOCS-DRAFT.md | 7 +++++-- docs/guides/live-queries.md | 2 +- packages/db/mangle-cache.json | 3 ++- packages/db/src/query/effect.ts | 14 ++++++++++---- .../src/query/live/bucket-facade-adapter.ts | 7 +++++++ .../db/tests/source-health-oracle.test.ts | 19 +++++++++++++++++-- .../tests/electric-oracle.property.test.ts | 2 ++ 9 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md index 98e980d044..bf02ab0a5a 100644 --- a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md +++ b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md @@ -1,7 +1,8 @@ # Candidate: the source health channel and live-query error tree -Status: candidate design, version 1. It came from a Field Lab design-grammar -run and has not been accepted. +Status: superseded candidate design, version 1. It came from a Field Lab +design-grammar run. The shipped design differs; read `PLAN.md`, the shipped +guides, and `packages/db/src/query/live/ARCHITECTURE.md` laws 16 and 17. ## Success standard (frozen, user-confirmed) diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index f55b719d94..b4a60f1448 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -46,10 +46,9 @@ names and shapes. - **Two axes.** Gate: `status` (`idle | loading | ready | error | cleaned-up`), decided only by an attempt's initial sync. Health: per Collection, changed only by its sync adapter after Collection readiness. -- **Adapter API.** Sync params gain `reportError(error, { final })` and - `reportHealthy()`. `markError` is for giving up before Collection readiness. - `markError` after Collection readiness is treated as - `reportError(error, { final: true })` with a development warning. +- **Adapter API.** Sync params gain `reportError(error)` and + `reportHealthy()`. `markError` means the sync adapter gave up: an initial + error before Collection readiness, a final health error after it. `markReady()` after `markError()` in one sync run throws. - **Collection surface.** `collection.health` and a `health:change` event. - **Error info.** `{ phase: 'initial' | 'sync' | 'request', final, name, diff --git a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md index e948938204..a4f87e9928 100644 --- a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md +++ b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md @@ -1,7 +1,10 @@ # Draft: what users would see (candidate v2) -This is a proposal, not shipped behavior. It applies the repairs from the -hostile assay and the fracture scan to `HEALTH-CANDIDATE.md`. +This is a superseded proposal, not shipped behavior. It applies the repairs +from the hostile assay and the fracture scan to `HEALTH-CANDIDATE.md`. The +shipped API differs (for example, `reportError` has no `final` option, and +query-db's error utilities are removed); read `PLAN.md` and the shipped +guides. --- diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 95a429d3f7..7e2446c3ff 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -2479,7 +2479,7 @@ createEffect({ }) ``` -If a source collection enters an error or cleaned-up state, the effect automatically disposes itself. Use `onSourceError` to handle this: +If a source collection enters an error state (an initial error or a crash) or is cleaned up, the effect automatically disposes itself. Use `onSourceError` to handle this. Other problems after the effect's initial load do not dispose it; they reach `onHealthChange`: ```ts createEffect({ diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 842fb9f93d..0bfc6f5d2f 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -417,5 +417,6 @@ "settleLoad": "gR", "endedProblem": "gS", "errorTree": "gT", - "failLoad": "gU" + "failLoad": "gU", + "settleWhenLoaded": "gV" } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index ebce3eedce..05980819a4 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -642,10 +642,7 @@ class EffectPipelineRunner { result, () => this.subscriptions[sourceId]?.pendingTruncateReplacement, ) - void Promise.resolve(result).then( - () => this.settleLoad(sourceId), - () => {}, - ) + this.settleWhenLoaded(sourceId, result) }, }) @@ -705,6 +702,7 @@ class EffectPipelineRunner { result, () => subscription.pendingTruncateReplacement, ) + this.settleWhenLoaded(sourceId, result) const holdsInitialPublication = this.skipInitial && !this.initialLoadComplete if (holdPublication || holdsInitialPublication) { @@ -1083,6 +1081,14 @@ class EffectPipelineRunner { this.reportHealth() } + /** A load that later succeeds clears the source's request error. */ + private settleWhenLoaded(sourceId: string, result: unknown): void { + void Promise.resolve(result).then( + () => this.settleLoad(sourceId), + () => {}, + ) + } + private settleLoad(sourceId: string): void { if (this.requests.settle(sourceId)) this.reportHealth() } diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index df4c658d87..4f293d4f4c 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -33,6 +33,8 @@ type FacadeEntry = { dispose: () => Promise sync: FacadeSync | undefined order: WeakMap + /** Whether the include node counts this facade as in error. */ + readonly inError: boolean } type SnapshotRow = { @@ -422,6 +424,8 @@ export class BucketFacadeAdapter { if (byBucket!.size === 0) this.entries.delete(edgeId) const retired = getOrCreate(this.retiredEntries, edgeId, () => new Map()) retired.set(bucketKey, entry) + // A retired facade leaves the include node's count. + if (entry.inError) this.onInstanceHealthChange() } private getEntry(edgeId: string, bucketKey: string): FacadeEntry { @@ -491,6 +495,9 @@ export class BucketFacadeAdapter { get sync() { return sync }, + get inError() { + return inError + }, order, } byBucket.set(bucketKey, entry) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index ad3da8ede4..c46bf42c9d 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1281,9 +1281,9 @@ describe(`include instances`, () => { }, }, }) - const write = (row: Row) => { + const write = (row: Row, type: `insert` | `delete` = `insert`) => { params.begin() - params.write({ type: `insert`, value: row }) + params.write({ type, value: row }) params.commit() } return { collection, write, ready: () => params.markReady() } @@ -1347,6 +1347,21 @@ describe(`include instances`, () => { expect(sameNode!.instances).toEqual({ total: 4, inError: 0 }) expect(sameNode!.hasErrors).toBe(false) expect(query.errors.hasErrors).toBe(true) + + // Removing the failing child's parent row retires that child, so it + // leaves the count and the query reports the change. + let retireEvents = 0 + const stopRetire = query.on(`health:change`, () => { + retireEvents++ + }) + B.write({ id: 1, parentId: 1 }, `delete`) + await flushPromises() + stopRetire() + expect(retireEvents, `retiring a child in error relays`).toBeGreaterThan( + 0, + ) + expect(includes()[0]!.instances).toEqual({ total: 3, inError: 0 }) + expect(query.errors.hasErrors).toBe(false) } finally { console.error = consoleError await query.cleanup() diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index 208e14f4f9..beaf07e17b 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -2458,6 +2458,7 @@ async function runProcessGrammar( runtime.resettingSnapshot = false runtime.reachedReadiness = false runtime.terminalError = false + runtime.terminalAfterReadiness = false runtime.active = true runtime.retired = false } @@ -2550,6 +2551,7 @@ async function runProcessGrammar( runtime.resettingSnapshot = false runtime.reachedReadiness = false runtime.terminalError = false + runtime.terminalAfterReadiness = false return `cleaned up` } if (command.kind === `retired-callback`) { From 369ef22e9541bc51aa90a1e490abb3cce72a6a55 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 16 Oct 2026 09:58:29 -0700 Subject: [PATCH 058/106] feat(db)!: split status into loading error and crash; failureReason/error tree Stage 1 of the error-surface redesign (docs/contributing/designs/ source-failure-and-health/error-surface). - CollectionStatus gains `crashed`: local storage, a throwing sync function, or an invariant broke; permanent for the Collection instance. A failed first load stays `error`. - Hooks expose isLoadingError, isCrashed, and isSyncError; isError is removed in every binding. - Error tree nodes carry `failureReason` (adapter still trying) or `error` (stopped, failed first load, crash, or a request error on a query's own node); `phase`, `final`, and node `isError` are gone; instances count `withErrors`. - Adapter verbs: markError becomes fail(error); reportError sets failureReason; reportHealthy clears it and is a no-op when nothing is wrong. Calls after fail() throw (error 247); calls after a crash do nothing. - health/health:change/onHealthChange become errors/errors:change/ onErrorsChange. - After a live query is ready only `crashed` propagates; a late source failure stays on the source node. A query reports its source's own cause. - Primary oracle, conformance (new crashed-status scenario), adapters, laws 14/16/17, and glossary updated with the contract. Co-authored-by: Isaac --- .../error-surface/ErrorSurface4.tla | 397 ++++++++++++++++ .../error-surface/README.md | 37 ++ .../error-surface/RULES.md | 83 ++++ .../error-surface/cfg/f1.cfg | 5 + .../error-surface/cfg/safe.cfg | 5 + .../cfg/w_CleanupWithSubscribers.cfg | 5 + .../error-surface/cfg/w_EmptyRestoreOpens.cfg | 5 + .../cfg/w_HealthyClearsError.cfg | 5 + .../error-surface/cfg/w_NoRunFence.cfg | 5 + .../error-surface/cfg/w_OneErrorField.cfg | 5 + .../error-surface/cfg/w_QueryErrorSticks.cfg | 5 + .../error-surface/cfg/w_ReloadBlanks.cfg | 5 + .../error-surface/cfg/w_ReloadCrashed.cfg | 5 + .../cfg/w_ReloadFailIsLoadingError.cfg | 5 + .../error-surface/cfg/w_ReloadHidden.cfg | 5 + .../cfg/w_ReloadShowsLoading.cfg | 5 + .../error-surface/cfg/w_SilentFromDisk.cfg | 5 + .../error-surface/readout.md | 46 ++ .../error-surface/tlc-results.tsv | 14 + .../error-surface/user-docs-draft.md | 323 ++++++++++++++ docs/contributing/glossary.md | 35 +- docs/contributing/oracle-coverage.md | 2 +- docs/errors.md | 26 +- packages/angular-db/src/index.ts | 14 +- packages/angular-db/tests/conformance.test.ts | 46 +- .../tests/inject-live-query.test.ts | 10 +- .../src/persisted.ts | 32 +- .../tests/persisted-oracle.test.ts | 144 +++--- .../tests/persisted-readiness-oracle.test.ts | 12 +- packages/db/e2e/indexed-db/browser.ts | 5 +- .../e2e/indexed-db/cross-tab-oracle.spec.ts | 8 +- packages/db/mangle-cache.json | 20 +- packages/db/src/collection/error-tree.ts | 39 +- packages/db/src/collection/events.ts | 16 +- packages/db/src/collection/index.ts | 17 +- packages/db/src/collection/lifecycle.ts | 166 ++++--- packages/db/src/collection/subscription.ts | 2 + packages/db/src/collection/sync.ts | 30 +- packages/db/src/indexed-db.ts | 18 +- packages/db/src/live-query-adapter.ts | 8 +- packages/db/src/live-query-observer.ts | 33 +- .../db/src/live-query-window-controller.ts | 9 +- packages/db/src/query/effect.ts | 51 ++- packages/db/src/query/live/ARCHITECTURE.md | 96 ++-- .../src/query/live/bucket-facade-adapter.ts | 27 +- .../query/live/collection-config-builder.ts | 87 ++-- packages/db/src/query/pooled-live-query.ts | 21 +- packages/db/src/types.ts | 72 ++- .../collection-cleanup-restart-oracle.test.ts | 25 +- packages/db/tests/collection-errors.test.ts | 32 +- .../db/tests/collection-lifecycle.test.ts | 23 +- ...collection-mutation-startup-oracle.test.ts | 2 +- ...ction-query-publication-boundaries.test.ts | 4 +- .../collection-subscribe-changes.test.ts | 2 +- ...tion-subscription-lifecycle-oracle.test.ts | 54 +-- .../tests/conformance-disabled-laws.test.ts | 8 +- .../db/tests/conformance-result-laws.test.ts | 9 +- packages/db/tests/conformance/contract.ts | 32 +- .../db/tests/conformance/disabled-laws.ts | 2 +- packages/db/tests/conformance/error-source.ts | 42 ++ .../db/tests/conformance/infinite-contract.ts | 2 +- .../conformance/infinite-suite-oracle.ts | 2 +- packages/db/tests/conformance/result-laws.ts | 4 +- packages/db/tests/conformance/suite.ts | 218 ++++----- .../{health-source.ts => sync-source.ts} | 12 +- .../db-client-preload-identity-oracle.test.ts | 9 +- packages/db/tests/effect.test.ts | 6 +- .../tests/fixtures/error-site-messages.json | 27 +- .../index-update-oracle.property.test.ts | 2 +- packages/db/tests/indexed-db/ORACLE.md | 2 +- .../indexed-db/compatibility-oracle.test.ts | 2 +- .../cross-tab-boundary-oracle.test.ts | 15 +- .../indexed-db/deletion-queue-oracle.test.ts | 16 +- packages/db/tests/indexed-db/recorder.ts | 13 +- .../indexed-db/retirement-oracle.test.ts | 18 +- .../tests/indexed-db/transport-oracle.test.ts | 26 +- ...-query-deferred-acquisition-oracle.test.ts | 13 +- packages/db/tests/live-query-observer.test.ts | 8 +- .../live-query-window-controller.test.ts | 19 +- .../tests/local-storage-peer-oracle.test.ts | 8 +- packages/db/tests/local-storage.test.ts | 2 +- packages/db/tests/preload-gc.test.ts | 8 +- .../query/includes-temporal-oracle.test.ts | 2 +- .../tests/query/live-query-collection.test.ts | 8 +- ...ad-subset-replay-refinement-oracle.test.ts | 4 +- .../query/pooled-live-query-gc-oracle.test.ts | 4 +- .../tests/query/subset-error-matrix.test.ts | 13 +- .../db/tests/source-health-oracle.test.ts | 422 ++++++++++-------- .../tests/truncate-readiness-oracle.test.ts | 2 +- .../electric-db-collection/src/electric.ts | 30 +- ...ectric-descriptor-isolation-oracle.test.ts | 14 +- .../tests/electric-oracle.property.test.ts | 15 +- .../electric-resume-snapshot-races.test.ts | 8 +- .../src/PowerSyncTransactor.ts | 3 +- .../powersync-db-collection/src/powersync.ts | 8 +- .../tests/load-hooks.test.ts | 6 +- .../tests/on-demand-sync.test.ts | 20 +- .../tests/transactor-readiness.test.ts | 2 +- packages/query-db-collection/src/query.ts | 14 +- .../load-subset-lifecycle-oracle.test.ts | 57 +-- .../tests/ownership-lifecycle.oracle.test.ts | 13 +- .../query-db-collection/tests/query.test.ts | 13 +- packages/react-db/src/useLiveInfiniteQuery.ts | 12 +- packages/react-db/src/useLiveQuery.ts | 39 +- packages/react-db/src/useLiveSuspenseQuery.ts | 17 +- packages/react-db/tests/conformance.test.tsx | 47 +- .../tests/infinite-query-conformance.test.tsx | 2 +- .../tests/useLiveInfiniteQuery.test.tsx | 2 +- packages/react-db/tests/useLiveQuery.test.tsx | 10 +- .../tests/useLiveSuspenseQuery.test.tsx | 8 +- packages/rxdb-db-collection/src/rxdb.ts | 4 +- .../rxdb-db-collection/tests/rxdb.test.ts | 4 +- packages/solid-db/src/useLiveQuery.ts | 27 +- packages/solid-db/tests/conformance.test.tsx | 48 +- packages/solid-db/tests/useLiveQuery.test.tsx | 8 +- .../src/useLiveInfiniteQuery.svelte.ts | 10 +- packages/svelte-db/src/useLiveQuery.svelte.ts | 36 +- .../tests/conformance.svelte.test.ts | 46 +- .../infinite-query-conformance.svelte.test.ts | 2 +- .../trailbase-db-collection/src/trailbase.ts | 18 +- .../tests/lifecycle-oracle.property.test.ts | 43 +- .../tests/trailbase.test.ts | 20 +- packages/vue-db/src/useLiveInfiniteQuery.ts | 18 +- packages/vue-db/src/useLiveQuery.ts | 29 +- packages/vue-db/tests/conformance.test.ts | 46 +- .../tests/infinite-query-conformance.test.ts | 2 +- 126 files changed, 2567 insertions(+), 1272 deletions(-) create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/README.md create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/RULES.md create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/f1.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/safe.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_CleanupWithSubscribers.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_EmptyRestoreOpens.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_HealthyClearsError.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_NoRunFence.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_OneErrorField.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_QueryErrorSticks.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadBlanks.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadCrashed.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadFailIsLoadingError.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadHidden.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadShowsLoading.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_SilentFromDisk.cfg create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/readout.md create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/tlc-results.tsv create mode 100644 docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md create mode 100644 packages/db/tests/conformance/error-source.ts rename packages/db/tests/conformance/{health-source.ts => sync-source.ts} (82%) diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla b/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla new file mode 100644 index 0000000000..acc62b0884 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla @@ -0,0 +1,397 @@ +---------------------------- MODULE ErrorSurface4 --------------------------- +(***************************************************************************) +(* Frozen grammar v4 (frozen-grammar.md) as a transition system. *) +(* *) +(* One live query over two sources: *) +(* P a persisted, eager source (restores rows from disk, syncs remote) *) +(* D an on-demand, remote-only source (may load after the query ready) *) +(* *) +(* Hidden state says what really happened; the Node*/qStatus projections *) +(* are what the app sees. RemedyFromObs reads only those; Oracle reads *) +(* hidden state. Weak holds negative-control weakenings. *) +(***************************************************************************) +EXTENDS Naturals, FiniteSets + +CONSTANTS Weak + +P == "P" +D == "D" +Sources == {P, D} + +VARIABLES + gate, \* R1: "loading" | "ready" | "error" | "crashed" | "cleaned" + problem, \* R4/R5 after-ready node problem: "none" | "failure" | "error" + rows, \* the source has readable rows + known, \* the source's rows are known to be its data + reloading, \* R7: a reload's new run is in flight + stale, \* R7: a superseded run may still deliver + fromStale, \* hidden: rows came from a superseded run + restore, \* R8 (P): "pending" | "synced" | "never" | "failed" + remoteUp, \* P's remote has synced in this run + remoteFail, \* hidden: P's remote is failing and nobody has reported it + remoteFailedEarly, \* R8: P's remote failed before the restore finished + qStatus, \* R3: "loading" | "ready" | "error" | "crashed" | "cleaned-up" + shown, \* the query has shown rows in this run + req, \* R4 request: "none" | "pending" | "failed" + missing, \* hidden: requested data has not arrived + mounted, \* the query subscribes to both sources + errReloaded \* hidden: a reload committed while the query was in 'error' + +vars == <> + +W(x) == x \in Weak + +\* R3 and R9. A query's 'error' clears once every source is ready again, +\* which needs a reload's commit (ErrorClearsOnlyByReload); 'crashed' stays. +NewQ(q, g) == + IF q \in {"crashed", "cleaned-up"} THEN q + ELSE IF \E s \in Sources : g[s] \in {"crashed", "cleaned"} THEN "crashed" + ELSE IF q = "ready" THEN "ready" + \* F1 decision (b): once no source has a failure, the query leaves 'error' for + \* 'loading' or 'ready'. A source still reloading keeps its 'error' gate (R7). + ELSE IF q = "error" + THEN IF ~W("QueryErrorSticks") /\ \A s \in Sources : g[s] # "error" /\ problem'[s] # "error" + THEN (IF g[P] = "ready" /\ g[D] \in {"ready", "loading"} THEN "ready" ELSE "loading") + ELSE "error" + ELSE IF \E s \in Sources : g[s] = "error" THEN "error" + ELSE IF g[P] = "ready" /\ g[D] \in {"ready", "loading"} THEN "ready" + ELSE "loading" + +SettleR(g, rl, r) == + /\ gate' = g + /\ reloading' = rl + /\ qStatus' = IF mounted THEN NewQ(qStatus, g) ELSE qStatus + /\ shown' = (shown \/ qStatus' = "ready") + /\ req' = IF qStatus' = "ready" THEN r ELSE "none" + /\ missing' = IF qStatus' = "ready" THEN missing ELSE FALSE + /\ errReloaded' = ((qStatus = "error") /\ + (errReloaded \/ \E s \in Sources : reloading[s] /\ ~rl[s] /\ g[s] = "ready")) + +Settle(g, rl) == SettleR(g, rl, req) + +Init == + /\ gate = [s \in Sources |-> "loading"] + /\ problem = [s \in Sources |-> "none"] + /\ rows = [s \in Sources |-> FALSE] + /\ known = [s \in Sources |-> FALSE] + /\ reloading = [s \in Sources |-> FALSE] + /\ stale = [s \in Sources |-> FALSE] + /\ fromStale = FALSE + /\ restore = "pending" + /\ remoteUp = FALSE + /\ remoteFail = FALSE + /\ remoteFailedEarly = FALSE + /\ qStatus = "loading" + /\ shown = FALSE + /\ req = "none" + /\ missing = FALSE + /\ mounted = TRUE + /\ errReloaded = FALSE + +QUnchanged == UNCHANGED <> + +----------------------------------------------------------------------------- +(* Remote sync. *) + +\* R1 first ready, R7 a reload's first commit, R8 P's first remote sync. +RemoteReady(s) == + /\ \/ gate[s] = "loading" + \/ reloading[s] + \/ s = P /\ gate[P] = "ready" /\ ~remoteUp /\ problem[P] # "error" + /\ problem' = [problem EXCEPT ![s] = IF reloading[s] \/ (s = P /\ ~remoteUp) + THEN "none" ELSE problem[s]] + /\ rows' = [rows EXCEPT ![s] = TRUE] + /\ known' = [known EXCEPT ![s] = TRUE] + /\ remoteUp' = IF s = P THEN TRUE ELSE remoteUp + /\ remoteFail' = IF s = P THEN FALSE ELSE remoteFail + /\ Settle([gate EXCEPT ![s] = "ready"], [reloading EXCEPT ![s] = FALSE]) + /\ UNCHANGED <> + +\* R5 fail(e) before the current run's first commit. +RemoteFailBefore(s) == + /\ \/ gate[s] = "loading" /\ ~(s = P /\ remoteFailedEarly) + \/ reloading[s] + \/ s = P /\ gate[P] = "ready" /\ ~remoteUp /\ problem[P] # "error" + /\ CASE reloading[s] -> + \* R7: nothing visible changes except the node's new error. + /\ problem' = [problem EXCEPT ![s] = IF gate[s] = "ready" THEN "error" ELSE problem[s]] + /\ remoteFailedEarly' = remoteFailedEarly + /\ Settle(IF W("ReloadFailIsLoadingError") /\ gate[s] = "ready" + THEN [gate EXCEPT ![s] = "error"] ELSE gate, + [reloading EXCEPT ![s] = FALSE]) + [] gate[s] = "ready" -> + \* R8: P's gate opened from disk; the remote stopped before syncing. + /\ problem' = [problem EXCEPT ![s] = "error"] + /\ remoteFailedEarly' = remoteFailedEarly + /\ Settle(gate, reloading) + [] s = P /\ restore = "pending" -> + \* R8: wait for the restore result. + /\ remoteFailedEarly' = TRUE + /\ UNCHANGED problem + /\ Settle(gate, reloading) + [] OTHER -> + /\ remoteFailedEarly' = remoteFailedEarly + /\ UNCHANGED problem + /\ Settle([gate EXCEPT ![s] = "error"], reloading) + /\ remoteFail' = IF s = P THEN FALSE ELSE remoteFail + /\ UNCHANGED <> + +\* R8: P's remote has a problem while its gate is open from disk. +RemoteProblemFromDisk == + /\ gate[P] = "ready" /\ ~remoteUp /\ ~reloading[P] /\ problem[P] = "none" /\ ~remoteFail + /\ IF W("SilentFromDisk") + THEN remoteFail' = TRUE /\ UNCHANGED problem + ELSE problem' = [problem EXCEPT ![P] = "failure"] /\ UNCHANGED remoteFail + /\ UNCHANGED <> + /\ QUnchanged + +\* R5 reportError / reportHealthy / fail after the current run's first commit. +ReportError(s) == + /\ gate[s] = "ready" /\ ~reloading[s] /\ problem[s] = "none" + /\ problem' = [problem EXCEPT ![s] = "failure"] + /\ UNCHANGED <> + /\ QUnchanged + +ReportHealthy(s) == + /\ gate[s] = "ready" /\ ~reloading[s] + /\ problem[s] = "failure" \/ (W("HealthyClearsError") /\ problem[s] = "error") + /\ problem' = [problem EXCEPT ![s] = "none"] + /\ UNCHANGED <> + /\ QUnchanged + +FailAfter(s) == + /\ gate[s] = "ready" /\ ~reloading[s] /\ problem[s] # "error" + /\ (s = P => remoteUp) + /\ problem' = [problem EXCEPT ![s] = "error"] + /\ UNCHANGED <> + /\ QUnchanged + +\* R7: a superseded run delivers late. +StaleCommit(s) == + /\ stale[s] + /\ stale' = [stale EXCEPT ![s] = FALSE] + /\ IF W("NoRunFence") + THEN fromStale' = TRUE /\ rows' = [rows EXCEPT ![s] = TRUE] + ELSE UNCHANGED <> + /\ UNCHANGED <> + /\ QUnchanged + +----------------------------------------------------------------------------- +(* Local persistence of P. *) + +RestoreDone(r) == + /\ restore = "pending" /\ gate[P] \in {"loading", "ready", "error"} + /\ restore' = r + /\ LET opens == gate[P] = "loading" /\ (r = "synced" \/ W("EmptyRestoreOpens")) + g == IF opens THEN [gate EXCEPT ![P] = "ready"] + ELSE IF remoteFailedEarly /\ gate[P] = "loading" + THEN [gate EXCEPT ![P] = "error"] ELSE gate + IN /\ rows' = [rows EXCEPT ![P] = rows[P] \/ opens] + /\ known' = [known EXCEPT ![P] = known[P] \/ r = "synced"] + /\ problem' = IF opens /\ remoteFailedEarly THEN [problem EXCEPT ![P] = "error"] ELSE problem + /\ Settle(g, reloading) + /\ remoteFailedEarly' = FALSE + /\ UNCHANGED <> + +\* R2/R8 a failed restore, or storage breaking later, is a crash. +StorageBreaks == + /\ gate[P] \in {"loading", "ready", "error"} + /\ restore' = IF restore = "pending" THEN "failed" ELSE restore + /\ rows' = [rows EXCEPT ![P] = FALSE] + /\ problem' = [problem EXCEPT ![P] = "none"] + /\ remoteFail' = FALSE + /\ Settle([gate EXCEPT ![P] = "crashed"], [reloading EXCEPT ![P] = FALSE]) + /\ UNCHANGED <> + +----------------------------------------------------------------------------- +(* App actions. *) + +\* R7. A reload in flight returns its promise (no transition). Crashed throws. +CanReload(s) == + /\ ~reloading[s] + /\ gate[s] \in {"loading", "ready", "error"} \/ (W("ReloadCrashed") /\ gate[s] = "crashed") + +StartReload(s, rl, r) == + IF gate[s] = "loading" + THEN \* a new run replaces the loading one + /\ stale' = [stale EXCEPT ![s] = TRUE] + /\ restore' = IF s = P THEN "pending" ELSE restore + /\ remoteUp' = IF s = P THEN FALSE ELSE remoteUp + /\ remoteFail' = IF s = P THEN FALSE ELSE remoteFail + /\ remoteFailedEarly' = IF s = P THEN FALSE ELSE remoteFailedEarly + /\ rows' = rows + /\ SettleR(gate, rl, r) + ELSE IF gate[s] = "crashed" + THEN \* only under Weak "ReloadCrashed" + /\ stale' = stale + /\ restore' = IF s = P THEN "pending" ELSE restore + /\ remoteUp' = IF s = P THEN FALSE ELSE remoteUp + /\ remoteFail' = IF s = P THEN FALSE ELSE remoteFail + /\ remoteFailedEarly' = IF s = P THEN FALSE ELSE remoteFailedEarly + /\ rows' = rows + /\ SettleR([gate EXCEPT ![s] = "loading"], rl, r) + ELSE \* ready or error: keep everything visible; the node shows reloading + /\ stale' = stale + /\ restore' = restore + /\ remoteUp' = remoteUp + /\ remoteFail' = IF s = P THEN FALSE ELSE remoteFail + /\ remoteFailedEarly' = remoteFailedEarly + /\ rows' = IF W("ReloadBlanks") THEN [rows EXCEPT ![s] = FALSE] ELSE rows + /\ IF W("ReloadShowsLoading") /\ gate[s] = "error" + THEN SettleR([gate EXCEPT ![s] = "loading"], [rl EXCEPT ![s] = FALSE], r) + ELSE SettleR(gate, [rl EXCEPT ![s] = TRUE], r) + +Reload(s) == + /\ CanReload(s) + /\ UNCHANGED <> + \* R7: a failed request is re-issued by the new run. + /\ StartReload(s, reloading, IF req = "failed" THEN "pending" ELSE req) + +\* R9: reload a live query in 'error': reload each failed source. +QueryReload == + /\ mounted /\ qStatus = "error" + /\ LET failed == {s \in Sources : (gate[s] = "error" \/ problem[s] = "error") /\ ~reloading[s]} + IN /\ failed # {} + /\ reloading' = [s \in Sources |-> reloading[s] \/ s \in failed] + /\ UNCHANGED <> + +\* cleanup() throws while the query subscribes; a cascade cleans the query first. +CleanupSource(s) == + /\ ~mounted \/ W("CleanupWithSubscribers") + /\ gate[s] # "cleaned" + /\ rows' = [rows EXCEPT ![s] = FALSE] + /\ problem' = [problem EXCEPT ![s] = "none"] + /\ known' = [known EXCEPT ![s] = FALSE] + /\ Settle([gate EXCEPT ![s] = "cleaned"], [reloading EXCEPT ![s] = FALSE]) + /\ UNCHANGED <> + +CleanupQuery == + /\ mounted + /\ mounted' = FALSE + /\ qStatus' = "cleaned-up" + /\ errReloaded' = FALSE + /\ req' = "none" /\ missing' = FALSE + /\ UNCHANGED <> + +\* A cleaned source preloads again: the recreated instance after a crash. +PreloadSource(s) == + /\ gate[s] = "cleaned" + /\ restore' = IF s = P THEN "pending" ELSE restore + /\ remoteUp' = IF s = P THEN FALSE ELSE remoteUp + /\ remoteFail' = IF s = P THEN FALSE ELSE remoteFail + /\ remoteFailedEarly' = IF s = P THEN FALSE ELSE remoteFailedEarly + /\ UNCHANGED <> + /\ Settle([gate EXCEPT ![s] = "loading"], reloading) + +MountQuery == + /\ ~mounted + /\ \A s \in Sources : gate[s] # "cleaned" + /\ UNCHANGED problem + /\ mounted' = TRUE + /\ qStatus' = NewQ("loading", gate) + /\ errReloaded' = FALSE + /\ shown' = (qStatus' = "ready") + /\ req' = "none" /\ missing' = FALSE + /\ UNCHANGED <> + +ReqVars == <> +RequestStart == qStatus = "ready" /\ req = "none" /\ req' = "pending" /\ missing' = TRUE /\ UNCHANGED ReqVars +RequestFail == req = "pending" /\ req' = "failed" /\ UNCHANGED <> /\ UNCHANGED ReqVars +RequestLoad == req = "pending" /\ req' = "none" /\ missing' = FALSE /\ UNCHANGED ReqVars +RequestRetry == req = "failed" /\ req' = "pending" /\ UNCHANGED <> /\ UNCHANGED ReqVars +RequestRetire == req # "none" /\ req' = "none" /\ missing' = FALSE /\ UNCHANGED ReqVars + +Next == + \/ \E s \in Sources : + RemoteReady(s) \/ RemoteFailBefore(s) \/ ReportError(s) \/ ReportHealthy(s) + \/ FailAfter(s) \/ StaleCommit(s) \/ Reload(s) \/ CleanupSource(s) \/ PreloadSource(s) + \/ \E r \in {"synced", "never"} : RestoreDone(r) + \/ RemoteProblemFromDisk \/ StorageBreaks + \/ QueryReload \/ CleanupQuery \/ MountQuery + \/ RequestStart \/ RequestFail \/ RequestLoad \/ RequestRetry \/ RequestRetire + +Spec == Init /\ [][Next]_vars + +----------------------------------------------------------------------------- +(* What the app observes. *) + +NodeFailureReason(s) == problem[s] = "failure" +NodeError(s) == + IF W("OneErrorField") THEN problem[s] # "none" \/ gate[s] = "error" + ELSE problem[s] = "error" \/ gate[s] = "error" +NodeReloading(s) == ~W("ReloadHidden") /\ reloading[s] + +RemedyFromObs == + CASE ~mounted -> {} + [] qStatus = "crashed" -> {"recreate"} + [] qStatus = "error" -> + IF \E s \in Sources : NodeError(s) /\ ~NodeReloading(s) THEN {"reload"} ELSE {"wait"} + [] qStatus = "loading" -> {} + [] OTHER -> + {r \in {"wait", "reload"} : + \E s \in Sources : + \/ r = "wait" /\ (NodeReloading(s) + \/ (NodeFailureReason(s) /\ ~NodeError(s))) + \/ r = "reload" /\ NodeError(s) /\ ~NodeReloading(s)} + \cup (IF req = "failed" THEN {"retry-action"} ELSE {}) + +Oracle == + CASE ~mounted -> {} + [] qStatus = "crashed" -> {"recreate"} + [] qStatus = "error" -> + IF \E s \in Sources : (gate[s] = "error" \/ problem[s] = "error") /\ ~reloading[s] + THEN {"reload"} ELSE {"wait"} + [] qStatus = "loading" -> {} + [] OTHER -> + {r \in {"wait", "reload"} : + \E s \in Sources : + \/ r = "wait" /\ (reloading[s] \/ problem[s] = "failure" + \/ (s = P /\ remoteFail)) + \/ r = "reload" /\ ~reloading[s] /\ (problem[s] = "error" \/ gate[s] = "error")} + \cup (IF req = "failed" THEN {"retry-action"} ELSE {}) + +----------------------------------------------------------------------------- +(* Invariants. *) + +I1_LoadingErrorNeverAfterData == (mounted /\ qStatus = "error") => ~shown +I2_AfterReadyOnlyCrash == (mounted /\ shown) => qStatus \in {"ready", "crashed"} +I3_ReadyShowsRows == (mounted /\ qStatus = "ready") => rows[P] +I4_RemedyDecodable == RemedyFromObs = Oracle +I6_NoCleanupUnderQuery == mounted => \A s \in Sources : gate[s] # "cleaned" +I7_NoStaleRows == ~fromStale +I8_ReadyMeansKnown == gate[P] = "ready" => known[P] +NoSilentMissing == (qStatus = "ready" /\ missing) => req \in {"pending", "failed"} +LocalRowsNotHidden == restore = "synced" => gate[P] # "error" + +ErrorClearsOnlyByReload == + [][\A s \in Sources : + ((problem[s] = "error" /\ problem'[s] = "none" /\ gate'[s] = "ready") + => reloading[s]) + /\ ((gate[s] = "error" /\ gate'[s] = "ready") => reloading[s])]_vars +QueryErrorClearsOnlyByReload == + [][(qStatus = "error" /\ qStatus' = "ready") => + (errReloaded \/ \E s \in Sources : reloading[s])]_vars +\* A query in 'error' can always get out: some source is failed (reload it) or reloading. +QueryErrorHasAWayOut == + (mounted /\ qStatus = "error") => + \E s \in Sources : reloading[s] \/ gate[s] \in {"error", "loading"} \/ problem[s] = "error" +\* Finding F1 (run 4): a query can sit in 'error' with no failed source while another +\* source is still loading its first ready. Recorded by this invariant: +QueryErrorNamesAFailure == + (mounted /\ qStatus = "error") => + \E s \in Sources : reloading[s] \/ gate[s] = "error" \/ problem[s] = "error" +CrashPermanentForInstance == + [][\A s \in Sources : gate[s] = "crashed" => gate'[s] \in {"crashed", "cleaned"}]_vars +ReloadNeverShowsLoading == + [][\A s \in Sources : (gate[s] = "error" /\ gate'[s] = "loading") => FALSE]_vars +============================================================================= diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/README.md b/docs/contributing/designs/source-failure-and-health/error-surface/README.md new file mode 100644 index 0000000000..8095c49d8d --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/README.md @@ -0,0 +1,37 @@ +# Error surface (2026-10-16) + +This folder supersedes the health-channel surface in `../HEALTH-CANDIDATE.md` and the +`phase` / `final` error tree. The maintainer chose it after a Field Lab study of how other +libraries separate loading errors from errors after data is shown. + +- `RULES.md`: the frozen rules (grammar v4) and invariants. This is the contract the + implementation follows. +- `ErrorSurface4.tla`, `cfg/`, `tlc-results.tsv`: the TLA+ model of those rules, its safe + configuration, and twelve weakened controls, each of which fails at its target. + Run from the field-lab skill root: `scripts/tlc -deadlock -config cfg/safe.cfg ErrorSurface4`. +- `readout.md`: what the model checks and what it does not. +- `user-docs-draft.md`: the developer-facing guide written against the rules before + implementation. + +Decisions, in short: + +1. `status` answers "can the data be shown": `idle | loading | ready | error | crashed | + cleaned-up`, one flag per value (`isLoadingError`, `isCrashed`, ...). After a query has + shown data, only a crash changes its status. `isError` is deprecated. +2. `errors` answers "is the data still kept current". Each node carries `failureReason` + (the adapter still tries) or `error` (it stopped), like TanStack Query. `isSyncError` is + `status === 'ready'` and any problem in the tree. Every Collection has `errors` and + `errors:change`; the `health` names are gone. +3. Adapter calls: `markReady()`, `reportError(e)`, `fail(e)`, `reportHealthy()`. A call that + does not fit the state machine throws; `reportHealthy()` with nothing wrong is a no-op. +4. `reload()` starts a new sync run like a sync-side truncate: nothing visible changes until + it commits or fails. It returns the in-flight promise when called again, throws on + `crashed`, and works during `loading` and after `error`. A live query's `reload()` + reloads its failed sources. +5. `cleanup()` throws while the Collection has subscribers; `subscribers` lists them and + `cleanup({ dependents: true })` cleans them up first. A crash is permanent for the + instance; recovery is a new Collection. +6. Persisted Collections join the same model: a synced disk restore opens the gate, a + failed remote waits for the restore, remote problems after that are reported, and a + failed restore is a crash. `persistedStatus`, `isPersistedReady`, and `persistedError` + are removed. diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md b/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md new file mode 100644 index 0000000000..639062e05f --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md @@ -0,0 +1,83 @@ +# Frozen grammar v4: app-facing error model for a reactive local-first data library + +A *Collection* holds rows kept in sync with a backend by a *sync adapter*. A *live query* +reads one or more Collections ("sources", one per query alias) and is itself a Collection. +Apps read a live query through framework hooks. Some Collections are *persisted*: they +restore rows from local disk storage and also sync with a remote backend. + +## Surface +- `status`: 'idle' | 'loading' | 'ready' | 'error' | 'crashed' | 'cleaned-up' +- One boolean per status: isIdle, isLoading, isReady, isLoadingError (status 'error'), + isCrashed, isCleanedUp. `isError` is deprecated. +- `isSyncError`: status is 'ready' and the tree holds any `failureReason` or `error`. +- `errors`: a tree, one node per query alias plus a root node for the query. Each node: + { alias, kind: 'source'|'live-query'|'include', status, hasErrors, error | null, sources }. + A source node carries at most one of: + - `failureReason`: the latest problem while the adapter keeps trying (like TanStack Query's + `failureReason` during retries); + - `error`: the adapter stopped (`fail`), or the gate failed. + Each value is { name, message, code?, at, cause }. A node also shows `reloading` while a + reload's new run is in flight. The query's own node carries a request `error`. +- `collection.health`: the Collection's current after-ready error, or null. +- `collection.reload()`: start a new sync run while keeping the current rows (see R7). +- `collection.cleanup()`: throws while the Collection has subscribers. The app can list a + Collection's subscribers (live queries, Effects, raw subscriptions) and ask cleanup to clean + up those dependents first. + +## Rules +R1 Gate. Each sync run: loading -> ready | error | crashed. An adapter that keeps retrying + before its first ready reports nothing (status stays loading). 'error' (gave up before the + first ready) is final for the run. +R2 Crash. 'crashed' = the Collection can no longer serve reads or accept writes: local + storage broken (closed IndexedDB, a failed local write or restore) or an internal invariant + failure. Final for the run; ends every live query and Effect over it. The cause may or may + not persist into a new run. +R3 Query gate. Until a live query is ready, it takes its sources' worst gate + (crashed > error > loading > ready). After it is ready, only 'crashed' propagates. +R4 After ready, a failure never changes status or removes rows. It appears on the node that + owns it: + - source node, sync problem: `failureReason` (adapter still trying; clears itself) or + `error` (adapter stopped; rows frozen until reload); + - a source whose new sync run fails before its first ready while the query is ready + (an on-demand source loading late, or a source being reloaded): node `error`; the query + stays ready; + - query's own node, request error: a request for more data (next page, newly joined rows) + failed; clears when that data loads or is no longer needed. +R5 Adapter verbs: markReady(); reportError(e) = something went wrong, still running + (sets `failureReason`); fail(e) = stopped (before ready -> status 'error'; after -> node + `error`); reportHealthy() clears `failureReason`. A node `error` clears only when a + reload's new run commits. +R6 Tree identity is stable while content is unchanged; every field except `cause` is JSON. +R7 Reload. reload() starts a new sync run like a sync-side truncate. Nothing visible changes + until the new run's first commit or failure: rows, status ('ready' or 'error'), and the + current error stay, and the node shows `reloading`. The first commit replaces all rows + atomically and clears the node's problem (an 'error' status becomes 'ready'). If the new + run fails first, the old rows and status stay and the node shows the new `error`. + reload() while a reload is in flight returns that reload's promise. reload() on a + 'crashed' Collection throws: a crash is permanent for the instance; recovery is a new + Collection. reload() during 'loading' starts a new run; commits from the old run are + ignored. A failed request is re-issued by the new run. +R9 Live-query reload. reload() on a live query in 'error' reloads each of its sources in + 'error' or with a node `error`. Its status stays 'error' while any source still has a + failure, including a source whose reload is in flight (it keeps its 'error' gate). Once no + source has a failure, the query moves to 'loading', or straight to 'ready'. Adapters retry inside their + first load (for example query-db's retry or the queryFn) before calling fail(). +R8 Persisted Collections. A completed local restore of previously synced rows opens the gate + (status 'ready') even if the remote has not connected; remote problems after that are + reported (R4/R5), including before the remote's first sync. A remote that fails before the + restore finishes waits for the restore: synced rows -> 'ready' with node `error`; none -> + 'error'. A failed restore is a crash. An empty restore of a Collection that never synced + does not open the gate. + +## Invariants +I1 isLoadingError is never true after the query has shown data in its run. +I2 After a query is ready, its status is 'ready' or 'crashed' until it is recreated. +I3 Status 'ready' implies rows are shown; no after-ready failure or reload blanks rows. +I4 From what the app can observe, it can pick one remedy: wait, retry the action, reload, + report broken storage. +I5 No silent recovery or silent degradation: 'error' becomes 'ready' only through a reload's + commit; 'crashed' never becomes 'ready' on the same instance; a failing remote is never + shown as healthy; a reload never shows 'loading' in place of 'error'. +I6 'crashed' always means broken local storage or an invariant failure. +I7 Rows shown never come from a superseded run. +I8 'ready' never means "no rows" when the truth is "unknown". diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/f1.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/f1.cfg new file mode 100644 index 0000000000..b45e2b7b36 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/f1.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut QueryErrorNamesAFailure +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/safe.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/safe.cfg new file mode 100644 index 0000000000..944c641641 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/safe.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_CleanupWithSubscribers.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_CleanupWithSubscribers.cfg new file mode 100644 index 0000000000..7cd562494a --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_CleanupWithSubscribers.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"CleanupWithSubscribers"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_EmptyRestoreOpens.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_EmptyRestoreOpens.cfg new file mode 100644 index 0000000000..a1ea5f4cac --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_EmptyRestoreOpens.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"EmptyRestoreOpens"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_HealthyClearsError.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_HealthyClearsError.cfg new file mode 100644 index 0000000000..c752e24d53 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_HealthyClearsError.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"HealthyClearsError"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_NoRunFence.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_NoRunFence.cfg new file mode 100644 index 0000000000..0d29d03e39 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_NoRunFence.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"NoRunFence"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_OneErrorField.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_OneErrorField.cfg new file mode 100644 index 0000000000..299cee00a4 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_OneErrorField.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"OneErrorField"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_QueryErrorSticks.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_QueryErrorSticks.cfg new file mode 100644 index 0000000000..116c64ff14 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_QueryErrorSticks.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"QueryErrorSticks"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadBlanks.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadBlanks.cfg new file mode 100644 index 0000000000..fbfe805119 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadBlanks.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"ReloadBlanks"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadCrashed.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadCrashed.cfg new file mode 100644 index 0000000000..07a7626a99 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadCrashed.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"ReloadCrashed"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadFailIsLoadingError.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadFailIsLoadingError.cfg new file mode 100644 index 0000000000..9765d617eb --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadFailIsLoadingError.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"ReloadFailIsLoadingError"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadHidden.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadHidden.cfg new file mode 100644 index 0000000000..a3481cde08 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadHidden.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"ReloadHidden"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadShowsLoading.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadShowsLoading.cfg new file mode 100644 index 0000000000..d2a76cedd4 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_ReloadShowsLoading.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"ReloadShowsLoading"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_SilentFromDisk.cfg b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_SilentFromDisk.cfg new file mode 100644 index 0000000000..c546d23b34 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/cfg/w_SilentFromDisk.cfg @@ -0,0 +1,5 @@ +CONSTANTS + Weak = {"SilentFromDisk"} +SPECIFICATION Spec +INVARIANTS I1_LoadingErrorNeverAfterData I2_AfterReadyOnlyCrash I3_ReadyShowsRows I4_RemedyDecodable I6_NoCleanupUnderQuery I7_NoStaleRows I8_ReadyMeansKnown NoSilentMissing LocalRowsNotHidden QueryErrorHasAWayOut +PROPERTIES ErrorClearsOnlyByReload QueryErrorClearsOnlyByReload CrashPermanentForInstance ReloadNeverShowsLoading diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/readout.md b/docs/contributing/designs/source-failure-and-health/error-surface/readout.md new file mode 100644 index 0000000000..9ca509945a --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/readout.md @@ -0,0 +1,46 @@ +# Design grammar readout: TanStack DB error surface, run 4 (2026-10-16) + +Exploratory run against frozen grammar v4 (`frozen-grammar.md`), which encodes the user's +decisions: TanStack Query-style `failureReason` / `error` per node; adapter verbs +`reportError` / `fail` / `reportHealthy`; `reload()` keeps everything visible until its new +run commits or fails (status 'error' stays 'error'); reload during a reload returns the same +promise; reload on 'crashed' throws; reload during 'loading' is allowed; failed requests are +re-issued; `cleanup()` refuses subscribers; persisted restore opens the gate, waits for the +restore before a loading error, and reports remote problems after. + +## What this tells us +Under v4 the app can always choose one fix from what it sees (wait, retry the action, +reload, recreate) across 10,516 states of one query over a persisted and an on-demand +source. Twelve deliberate weakenings each fail at their target: one error field instead of +two, a hidden reload, silent remote failure behind disk rows, `reportHealthy` clearing a +stopped node, no stale-run fence, empty restore opening the gate, reload on a crashed +Collection, reload blanking rows, reload showing 'loading' over 'error', cleanup with +subscribers, a failed reload becoming a loading error, and a query 'error' that never clears. + +## What it changes +- The TanStack Query split replaces `retrying`/`final`: `failureReason` = wait, + `error` = reload. One error field cannot carry that (control OneErrorField). +- A query's 'error' clears without its own reload once its sources are ready again through + reloads; `query.reload()` is a convenience that reloads its failed sources. +- A crash is permanent for the instance; recovery is cascade cleanup and a new Collection. + +## What it does not tell us +- **F1 (decided: b):** a query in 'error' leaves it for 'loading' once no source has a + failure (for example D's reload committed while P is still on its first load), then + becomes 'ready'. A source still reloading keeps its 'error' gate, so a reload never shows + 'loading' in place of 'error'. Retries before a first ready belong inside the adapter + (query-db's retry or the queryFn), so this window should be rare. +- Scope: one query, two sources; no Effects, includes, nested queries, tabs; no liveness. +- The remedy table is analyst-written; no reader evidence for v4 names. +- Run 3's B3 result was weaker than reported: an encoding bug disabled reload whenever a + request had failed. Fixed here; re-issue now exercised. + +## Concrete case +Backend down at startup: query 'error', D's node `error`. The app calls `query.reload()` +every 30 s; the node shows `reloading` and status stays 'error' with no flicker. When the +backend returns, the commit lands and the query becomes 'ready'. + +## Evidence +safe (with F1 b): pass, 10,560 distinct states; f1 check passes (TLC 2.19). Controls: summary.tsv. F1: cfg/f1.cfg, +out/f1.out. Encoding receipts: two (Settle/Reload double assignment of req; stuck query +error after a direct source reload), both noted above. diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/tlc-results.tsv b/docs/contributing/designs/source-failure-and-health/error-surface/tlc-results.tsv new file mode 100644 index 0000000000..1927457c37 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/tlc-results.tsv @@ -0,0 +1,14 @@ +f1 Model checking completed. No error has been found. 10560 distinct states found, 0 states left on queue. +safe Model checking completed. No error has been found. 10560 distinct states found, 0 states left on queue. +w_CleanupWithSubscribers Error: Invariant I6_NoCleanupUnderQuery is violated. 5 distinct states found, 3 states left on queue. +w_EmptyRestoreOpens Error: Invariant I8_ReadyMeansKnown is violated. 9 distinct states found, 7 states left on queue. +w_HealthyClearsError Error: Action property ErrorClearsOnlyByReload is violated. 79 distinct states found, 66 states left on queue. +w_NoRunFence Error: Invariant I7_NoStaleRows is violated. 33 distinct states found, 28 states left on queue. +w_OneErrorField Error: Invariant I4_RemedyDecodable is violated. 12 distinct states found, 9 states left on queue. +w_QueryErrorSticks Error: Invariant QueryErrorHasAWayOut is violated. 888 distinct states found, 643 states left on queue. +w_ReloadBlanks Error: Invariant I3_ReadyShowsRows is violated. 14 distinct states found, 11 states left on queue. +w_ReloadCrashed Error: Action property CrashPermanentForInstance is violated. 67 distinct states found, 56 states left on queue. +w_ReloadFailIsLoadingError Error: Invariant LocalRowsNotHidden is violated. 294 distinct states found, 233 states left on queue. +w_ReloadHidden Error: Invariant I4_RemedyDecodable is violated. 14 distinct states found, 11 states left on queue. +w_ReloadShowsLoading Error: Action property ReloadNeverShowsLoading is violated. 47 distinct states found, 41 states left on queue. +w_SilentFromDisk Error: Invariant I4_RemedyDecodable is violated. 62 distinct states found, 53 states left on queue. diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md new file mode 100644 index 0000000000..7a48a28312 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md @@ -0,0 +1,323 @@ +--- +title: Error Handling +id: error-handling +--- + + + +## Loading and sync errors + +An app that shows synced data asks two questions: + +1. **Can I show the data?** Read `status`. +2. **Is the data on screen still being kept up to date?** Read `isSyncError` and `errors`. + +TanStack DB answers them separately. Once a query has shown data, a network or backend +failure never takes the data off the screen. It shows up in `errors`, and the rows stay. + +```tsx +function TaskList() { + const { + data, + isLoading, + isLoadingError, + isCrashed, + isSyncError, + errors, + collection, + } = useLiveQuery((q) => + q + .from({ task: tasks }) + .join({ user: users }, ({ task, user }) => eq(task.assigneeId, user.id)), + ) + + if (isLoading) return + if (isLoadingError) return collection.reload()} /> + if (isCrashed) return + + return ( + <> + {isSyncError && ( + collection.reload()} /> + )} + {data.map((row) => ( + + ))} + + ) +} +``` + +### Status + +| `status` | Flag | Meaning | What the app does | +| ------------ | ---------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `idle` | `isIdle` | Sync has not started. | Nothing yet. | +| `loading` | `isLoading` | The first data is on its way. | Show a placeholder. | +| `ready` | `isReady` | The data can be shown. | Show it. Check `isSyncError` for a banner. | +| `error` | `isLoadingError` | The first load failed. There is nothing to show. | Show a failure screen and offer `reload()`. | +| `crashed` | `isCrashed` | Local storage broke. This Collection cannot be used again. | Recreate the Collection (see [When a Collection crashes](#when-a-collection-crashes)). | +| `cleaned-up` | `isCleanedUp` | The Collection was cleaned up. | Nothing. | + +Two rules make `status` predictable: + +- **After a query has shown data, only a crash changes its status.** A dropped connection, + a failed refetch, or a backend that stops answering appears in `errors`. `isLoadingError` + therefore always means "nothing to show yet". +- **A query takes the worst status of its sources until it is ready.** If any source fails + its first load, the query's status is `error`. + +`isError` is deprecated. It is `true` for `error` and `crashed`. Use `isLoadingError` and +`isCrashed`, which need different handling. + +### Sync errors: the `errors` tree + +`errors` has one node for the query itself and one for each source in the query, in query +order. The tree's shape is fixed by the query, so you can render, log, or send it to +monitoring. + +```ts +{ + alias: 'root', kind: 'live-query', status: 'ready', hasErrors: true, + failureReason: null, error: null, reloading: false, + sources: [ + { alias: 'task', kind: 'source', status: 'ready', hasErrors: false, + failureReason: null, error: null, reloading: false, sources: [] }, + { alias: 'user', kind: 'source', status: 'ready', hasErrors: true, + failureReason: { name: 'FetchError', message: 'Network request failed', at: 1760342400000 }, + error: null, reloading: false, sources: [] }, + ], +} +``` + +A node can show one of two problems. They follow TanStack Query's `failureReason` and +`error`: + +| Field | Meaning | What the app does | +| --------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------- | +| `failureReason` | Something went wrong, and the sync adapter is still trying. It clears by itself when sync works again. | Show "Reconnecting…" or nothing. Wait. | +| `error` | The sync adapter stopped. The rows from this source stay but no longer update. | Show "Updates stopped" and offer `reload()`. | + +`isSyncError` is `true` when the query is `ready` and any node has a `failureReason` or an +`error`. `hasErrors` on a node says whether that node or any node below it has one. + +Each problem is `{ name, message, code?, at, cause }`. `cause` holds the exact value the +adapter reported, for example the `Response` or the error your `queryFn` threw. Every other +field is plain JSON. A node keeps its object identity until something in its part of the +tree changes, so `===` and `useMemo` work on it. + +`failureReason` says that the adapter is trying, not that trying will help. An expired +token gives a `failureReason` on every refetch until the user signs in. Use `code` or +`cause` for cases like this: + +```ts +function SyncBanner({ errors, onReload }: { errors: ErrorTreeNode; onReload: () => void }) { + const [first] = findProblems(errors) // nodes with a problem, in query order + if (first?.failureReason?.code === '401') return + if (first?.error) return + return +} +``` + +`findProblems(errors)` walks the tree and returns every node with a `failureReason` or an +`error`, in query order, each with its `alias` path. Use it for the message on a failure +screen too: when `isLoadingError` is true, `findProblems(errors)[0].error` is the first +load failure that stopped the query. + +`onReload` here calls the query's `reload()`, which reloads every source that has an +`error`. To reload one source instead, call `reload()` on that source's Collection. + +### Collections without a live query + +Every Collection has the same surface, so code that uses a Collection directly does not +need a live query: + +```ts +todos.status // 'loading' | 'ready' | 'error' | 'crashed' | ... +todos.errors // one node: { status, failureReason, error, reloading, ... } +todos.on('errors:change', ({ errors }) => report(errors)) +await todos.reload() +``` + +A live query is a Collection too; its `errors` is the whole tree. + +### Failed requests for more data + +After a query is ready it can ask for more data: the next page of an infinite query, or +rows for a newly joined key. When that request fails, the query's own node (`root`) shows +an `error`. The sources stay healthy and the rows already shown stay. The error clears when +the data loads, or when the query no longer needs it, for example after the user scrolls +back. + +```tsx +const { data, errors, fetchNextPage } = useLiveInfiniteQuery(/* ... */) + +{ + errors?.error && ( + + ) +} +``` + +`fetchNextPage()` resolves either way. A reload of a source re-issues failed requests for +that source. + +### Reloading + +`collection.reload()` starts a new sync run for a Collection. It works like a sync-side +truncate: nothing visible changes until the new run's first commit. + +- The current rows, `status`, and any `error` stay on screen. The node shows + `reloading: true`. +- When the new run commits, its rows replace the old rows in one change, and the node's + problem clears. A `status` of `error` becomes `ready`. +- If the new run fails before it commits, the old rows and status stay, and the node shows + the new `error`. + +On a live query, `reload()` reloads each of its sources that has an `error`. + +```ts +// Retry a failed first load every 30 seconds until the backend is back. +useEffect(() => { + if (!isLoadingError) return + const id = setInterval(() => collection.reload(), 30_000) + return () => clearInterval(id) +}, [isLoadingError, collection]) +``` + +`reload()` returns a promise that settles when the new run commits or fails. Calling it +while a reload is in flight returns the same promise. On a `crashed` Collection it throws. + +Use `reload()` for retries the user or the app decides on. Retries inside one attempt belong +to the sync adapter: for example, set `retry` on a Query Collection, so a short outage never +reaches `isLoadingError`. + +While a query waits on a source's reload, its status stays `error`. Once no source has an +error, a query that is still waiting for another source's first load shows `loading`. + +### When a Collection crashes + +A Collection crashes when its local storage can no longer serve reads or accept writes: + +- its IndexedDB connection closed (for example after a `versionchange` from another tab); +- a local SQLite persistence write or restore failed; +- an internal invariant failed. + +The Collection moves to `crashed`, and so does every live query over it. A crash is +permanent for that Collection instance: `reload()` throws. To recover, clean up the +Collection together with everything that reads from it, then create a new one. + +```ts +console.log(todos.subscribers) // live queries, Effects, and raw subscriptions +await todos.cleanup({ dependents: true }) +const freshTodos = createCollection(todosOptions()) +``` + +### Cleaning up a Collection + +`cleanup()` throws while anything still subscribes to the Collection, because those +readers would lose their data. Unmount the components that read it first, or pass +`{ dependents: true }` to clean up its live queries and Effects first. +Collections that nothing reads are cleaned up automatically after `gcTime`. + +### Persisted Collections + +A persisted Collection restores its rows from local storage before the remote syncs. + +- **Restored rows open the Collection.** If local storage holds rows from an earlier sync, + the Collection becomes `ready` with them, even if the remote has not connected yet. Remote + problems after that appear in `errors` like any other sync problem. +- **A failed remote waits for the restore.** If the remote fails before the restore + finishes, the Collection waits: restored rows make it `ready` with an `error` on its node; + no stored rows make it `error`. +- **An empty local store does not open the Collection.** A Collection that has never synced + stays `loading` until the remote answers, so an empty list always means "no rows", not + "unknown". +- **A failed restore is a crash.** + +`persistedStatus`, `isPersistedReady`, and `persistedError` are removed. + +### Effects + +An Effect ends when a source it reads fails its first load or crashes; it calls +`onSourceError(error)`. After its initial load, any other problem calls +`onErrorsChange(errors)` with the same tree a live query returns, and the Effect keeps +running. + +### Suspense and error boundaries (React) + +`useLiveSuspenseQuery` suspends while `loading` and throws to the nearest error boundary for +`error` and `crashed`. Sync problems after the first data never reach the boundary; read +`errors` from the hook. + +## For adapter authors + +A sync adapter reports failures with three calls: + +| Call | Meaning | Effect | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | +| `reportError(error)` | Something went wrong; I am still trying. | Sets `failureReason` (after the first ready). | +| `fail(error)` | I stopped. | Before the first ready: `status: 'error'`. After: the node's `error`; the rows stay. | +| `reportHealthy()` | Sync works again. | Clears `failureReason`. | + +```ts +sync: ({ + begin, + write, + commit, + markReady, + reportError, + fail, + reportHealthy, +}) => { + let ready = false + const stream = connect({ + onData: (rows) => { + begin() + rows.forEach((row) => write({ type: 'insert', value: row })) + commit() + if (ready) reportHealthy() + else markReady() + ready = true + }, + onError: (error, { willRetry }) => { + if (!willRetry) fail(error) + else if (ready) reportError(error) + }, + }) + return () => stream.close() +} +``` + +- Retry inside the first load before calling `fail`. While you retry before the first + ready, call nothing; the Collection stays `loading`. +- These calls follow the Collection's state machine, and a call that does not fit it + throws: `reportError` or `reportHealthy` before the first ready, `markReady` after + `fail`, and any call after the sync run has ended. `reportHealthy` when nothing is wrong + is allowed and changes nothing, so you can call it after every commit. +- An `error` from `fail` clears only through `reload()`, which calls your `sync` function + again for a new run. + +## Migrating + +| Before | After | +| ------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `isError` | `isLoadingError` (first load failed) or `isCrashed` (storage broke) | +| `status: 'error'` after a crash | `status: 'crashed'` | +| `markError(error)` | `fail(error)` | +| `reportError(error, { final })` | `reportError(error)` or `fail(error)` | +| node `phase`, `final`, `isError` | node `failureReason` or `error` | +| `cleanup()` then `preload()` to recover | `reload()` | +| `cleanup()` with live queries mounted | throws; unmount first or `cleanup({ dependents: true })` | +| `persistedStatus`, `isPersistedReady`, `persistedError` | `status` and `errors` | +| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `errors`, `reload()` | +| `collection.health`, `health:change` | `collection.errors`, `errors:change` | +| Effect `onHealthChange` | `onErrorsChange` | diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 779965c15b..19fae66b6e 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -38,7 +38,7 @@ production queues, caches, or semantic helpers merely to share their names. | sync run | One invocation of a Collection's sync function, plus the callbacks and resources installed by that invocation, until cleanup invalidates them. A run may own zero or more provider requests and may outlive any one request. Component-owned `syncRunGeneration` counters fence this lifetime in Collection state, sync ownership, and live-query graph work. | Provider session, replay, generation, or request. | | provider session | A provider-defined remote stream, connection, or SDK lifetime. Always qualify it with the provider. | Sync run. | | cleanup | The transition that ends the current sync run and releases its resources. Callback invalidation and local teardown begin synchronously. The public cleanup promise and final `cleaned-up` status settle after adapter cleanup settles. The Collection object remains available for cleanup or restart. | Collection destruction, restart, or replay. | -| cleanup start | The internal boundary at which cleanup synchronously closes restart admission, invalidates the current sync run, puts dependent live queries in terminal error, and marks dependent Effects disposed. The public Collection status keeps its prior value while adapter cleanup is pending. | A Collection status, cleanup settlement, or proof that adapter resources or Effect handlers settled. | +| cleanup start | The internal boundary at which cleanup synchronously closes restart admission, invalidates the current sync run, crashes dependent live queries, and marks dependent Effects disposed. The public Collection status keeps its prior value while adapter cleanup is pending. | A Collection status, cleanup settlement, or proof that adapter resources or Effect handlers settled. | | restart | Starting a new sync run after the prior sync run has ended. | Same-run recovery or replay. | | truncate replay | An authoritative source replacement after a truncate, inside the current sync run. The Collection subscription remains active; any dependent live-query graph remains active behind its publication barrier. | Restart, reload, retry, or repair. | | source snapshot | The source rows established by one snapshot operation within its declared predicate and window. It proves full-source state only when the provider says that scope is authoritative and exhausted. | Public snapshot or proof of full-source coverage. | @@ -58,23 +58,22 @@ production queues, caches, or semantic helpers merely to share their names. `source session`. Use `sync run` for the local Collection sync invocation and a provider-qualified `provider session` for a remote lifecycle. -## Collection health terms +## Collection status and error terms -Collection status and Collection health are separate axes. Status decides -whether a sync run's initial sync may be shown; health reports what happens to -sync after that. Neither one changes the other. +Collection status and the error tree are separate axes. Status says whether the +data can be shown; the error tree says whether the data on screen is still kept +current. After Collection readiness, only a crash changes status. | Term | Meaning | Do not use as a synonym | | --- | --- | --- | -| initial gate | The role of Collection status during a sync run's initial sync. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `markError()`. After Collection readiness in a sync run, the status no longer reflects sync failures. | Collection health or a live query's request errors. | -| initial error | The `markError()` failure that ends a sync run's initial gate in `error`, recorded with `phase: 'initial'`. It is final for its sync run: `markReady()` afterward throws, and only a restart can reach `ready`. | Health error or a retry. | -| crash | Moving a Collection to `error` from any active status, including after Collection readiness, because the Collection can no longer serve reads or admit writes in its sync run. Core uses it for IndexedDB closure, terminal local persistence failures, and internal invariant failures. It is final for the sync run; only a restart recovers. | Initial error, final health error, or cleanup. | -| Collection health | A Collection's report of problems after Collection readiness in the current sync run, read through `collection.health` and `health:change`. A sync adapter changes it only through `reportError()`, `reportHealthy()`, and `markError()`. A live-query Collection's health also carries its own request errors. It never changes Collection status or rows. Cleanup resets it. | Collection status, `persistedError`, or subscription status. | -| health error | One problem reported through `reportError()` or, as a final health error, `markError()`, recorded as `CollectionErrorInfo` with `phase: 'sync'`. A health error that is not final means the sync adapter is still trying in the current sync run; `reportHealthy()` clears it. | Initial error or request error. | -| final health error | A health error with `final: true`, from `markError()` after Collection readiness: the sync adapter stopped trying in the current sync run. Only a restart clears it; `reportHealthy()` does not. | A Collection status of `error`. | -| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, recorded with `phase: 'request'` on that query's error tree node and, for a live query, in its live-query Collection's health. The source Collection's health does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Health error or request data. | -| error tree | The `errors` value of a live query or Effect: one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged. | Collection health or an error list. | -| error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. An include node summarizes all its per-row child Collections in `instances`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | +| initial gate | The role of Collection status during a sync run's first load. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `fail()`. After Collection readiness in a sync run, sync problems never change the status. | The error tree or a live query's request errors. | +| loading error | The `fail()` that ends a sync run's first load in status `error` (`isLoadingError`). It is final for its sync run: `markReady()` afterward throws, and only a new sync run can reach `ready`. | Sync error or a retry. | +| crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It is permanent for the Collection instance. After it, sync adapter calls do nothing. | Loading error, sync error, or cleanup. | +| sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error, `persistedError`, or subscription status. | +| failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | +| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Sync error or request data. | +| error tree | The `errors` value of a Collection, live query, or Effect: one node for a Collection; for a query, one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `errors:change` announces a change. | An error list. | +| error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. An include node summarizes all its per-row child Collections in `instances`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | ## Persistence coordination terms @@ -227,10 +226,10 @@ Use nouns for state and verbs for transitions: `cleaned-up`. Restart then **starts** a new sync run. - Truncate replay **replaces** source state inside the current sync run and any current dependent graph. -- A sync adapter **gives up** with `markError()`: an initial error before - Collection readiness, a final health error after it. While it keeps trying, - it **reports** a health error and **reports healthy**. - A restart **clears** a final health error. +- A sync adapter **fails** with `fail()`: a loading error before Collection + readiness, a node `error` after it. While it keeps trying, it **reports an + error** (a failure reason) and **reports healthy**. A new sync run + **clears** a node `error`. - A Collection **crashes** when it can no longer serve reads or admit writes. Use `settled` for a promise that fulfilled or rejected. Use `complete` for named diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 60bbd99408..5a5c42a885 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection health and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the initial gate, health problems with serials, and crashes derives each live query's gate and its expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, and restart; the check compares status, rows, the projected tree, node identity, and `status:change`/`health:change` ordering after every step. Request and Effect lanes cover request errors and Effect disposal on an initial error or crash. The infinite-query oracle keeps the window's pages and `ready` status after a failed window request. The shared framework conformance scenario `health-after-readiness` judges the same law through every hook. Adapter receivers: Electric's process grammar and partition laws, TrailBase's stream-end lane, PowerSync's rebuild outage, query-db's failed-refetch witness, and the persisted durability lanes. Open: on-demand sources that fail their own initial sync after a live query is ready are generated only in the Effect lane; a Suspense render after readiness has no direct framework witness; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection health and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the initial gate, health problems with serials, and crashes derives each live query's gate and its expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, and restart; the check compares status, rows, the projected tree, node identity, and `status:change`/`health:change` ordering after every step. Request and Effect lanes cover request errors and Effect disposal on an initial error or crash. The infinite-query oracle keeps the window's pages and `ready` status after a failed window request. The shared framework conformance scenario `sync-errors-after-readiness` judges the same law through every hook. Adapter receivers: Electric's process grammar and partition laws, TrailBase's stream-end lane, PowerSync's rebuild outage, query-db's failed-refetch witness, and the persisted durability lanes. Open: on-demand sources that fail their own initial sync after a live query is ready are generated only in the Effect lane; a Suspense render after readiness has no direct framework witness; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The virtual-clock owner checks queue order, bounded admission, cleanup draining in FIFO/LIFO order at the configured wait, admitted writes after separate Collection cleanup, post-cleanup queue rejection with a disposal reason, pending debounce drain at the last call's quiet edge, pending trailing throttle drain at its regular edge, debounce and throttle edge options including omitted options, immediate rejection of dropped leading-only calls and both-disabled calls, caller-option immutability, and settlement. New debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/docs/errors.md b/docs/errors.md index 89d4d6ab37..e3bf81a504 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1473,7 +1473,7 @@ Source collection '${collectionId}' was cleaned up while effect depends on it Development builds show this message, with `${...}` replaced by values: ```text -Source collection '${collectionId}' entered error state +Source collection '${collectionId}' entered ${status} state ``` @@ -1843,7 +1843,7 @@ No secure random number generator available: neither crypto.randomUUID nor crypt Development builds show this message, with `${...}` replaced by values: ```text -Source collection '${collectionId}' entered error state +Source collection '${collectionId}' crashed ``` @@ -2477,7 +2477,7 @@ A windowed or findOne result row has no include route Development builds show this message, with `${...}` replaced by values: ```text -${operation}() reports a problem after Collection readiness, but Collection "${this.id}" is ${this.status}. Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up. +${operation}() reports on sync after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call fail() when the sync adapter stops. ``` @@ -2489,3 +2489,23 @@ Development builds show this message, with `${...}` replaced by values: ```text The pooled live query's source ended ``` + + + +## Error 247: `collection/lifecycle.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +${operation}() was called after fail() in the same sync run of Collection "${this.id}". fail() means the sync adapter stopped; a reload starts a new sync run. +``` + + + +## Error 248: `query/live/collection-config-builder.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +Source collection '${collectionId}' failed its first load +``` diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 7c456df633..3c6fe924d1 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -51,10 +51,14 @@ export interface InjectLiveQueryFlags { persistedError: Signal /** A signal indicating whether the collection is idle */ isIdle: Signal - /** A signal indicating whether the collection has an error */ - isError: Signal + /** The first load failed: there is nothing to show. */ + isLoadingError: Signal + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: Signal /** A signal indicating whether the collection has been cleaned up */ isCleanedUp: Signal + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: Signal /** A signal containing the error tree for the query and its dependencies */ errors: Signal } @@ -310,8 +314,12 @@ export function injectLiveQuery(opts: any) { isPersistedReady: computed(() => persistedStatus() === `ready`), persistedError, isIdle: computed(() => status() === `idle`), - isError: computed(() => status() === `error`), + isLoadingError: computed(() => status() === `error`), + isCrashed: computed(() => status() === `crashed`), isCleanedUp: computed(() => status() === `cleaned-up`), + isSyncError: computed( + () => status() === `ready` && errors()?.hasErrors === true, + ), errors, } } diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 8e4ef12260..b7ae5ab16a 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -38,7 +38,8 @@ import { import { injectLiveQuery } from '../src/index' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' -import { makeHealthSource } from '../../db/tests/conformance/health-source' +import { makeSyncSource } from '../../db/tests/conformance/sync-source' +import { makeErrorSource as makeSharedErrorSource } from '../../db/tests/conformance/error-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' import type { @@ -113,27 +114,12 @@ function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { return { collection } } -function makeErrorSource() { - const expectedError = new Error(`conformance: sync failure`) - let startup: { returned: true } | { returned: false; error: unknown } = { - returned: true, - } - const collection = createCollection<{ id: string }>({ - id: `conformance-angular-err-${sourceSeq++}`, - getKey: (r) => r.id, - startSync: false, - sync: { - sync: () => { - throw expectedError - }, - }, - }) - try { - collection.startSyncImmediate() - } catch (error) { - startup = { returned: false, error } - } - return { collection, expectedError, startup } +function makeErrorSource(kind: `fail` | `crash`) { + return makeSharedErrorSource( + createCollection, + `conformance-angular-err-${sourceSeq++}`, + kind, + ) } async function settle() { @@ -152,7 +138,9 @@ function makeHandle(result: any, destroy: () => void): LiveQueryHandle { persistedStatus: result.persistedStatus(), isPersistedReady: result.isPersistedReady(), persistedError: result.persistedError(), - isError: result.isError(), + isLoadingError: result.isLoadingError(), + isCrashed: result.isCrashed(), + isSyncError: result.isSyncError(), // angular-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result.status() !== `disabled`, errors: result.errors(), @@ -235,8 +223,8 @@ const angularDriver: LiveQueryDriver = { createCollection, `conformance-angular-lagging-${sourceSeq++}`, ), - makeHealthSource: (data: ReadonlyArray) => - makeHealthSource( + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( createCollection, `conformance-angular-health-${sourceSeq++}`, data, @@ -387,7 +375,9 @@ it(`preserves raw result types through the actual driver reader`, () => { persistedStatus: `unavailable`, isPersistedReady: false, persistedError: undefined, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, errors: undefined, } @@ -400,9 +390,9 @@ it(`preserves raw result types through the actual driver reader`, () => { expect(healthy.data).toBe(raw.data) expect(healthy.state).toBe(raw.state) expect(healthy.isReady).toBe(false) - expect(healthy.isError).toBe(false) + expect(healthy.isLoadingError).toBe(false) expect(healthy.isEnabled).toBe(false) - for (const key of [`status`, `isReady`, `isError`]) { + for (const key of [`status`, `isReady`, `isLoadingError`]) { const original = raw[key] delete raw[key] expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index 1c90a76025..ff6e4db3aa 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -253,7 +253,7 @@ describe(`injectLiveQuery`, () => { expect(result.isReady()).toBe(true) expect(result.isLoading()).toBe(false) expect(result.isIdle()).toBe(false) - expect(result.isError()).toBe(false) + expect(result.isLoadingError()).toBe(false) expect(result.isCleanedUp()).toBe(false) }) }) @@ -297,7 +297,7 @@ describe(`injectLiveQuery`, () => { [], `idle`, ) - const { status, isReady, isLoading, isError, isCleanedUp } = + const { status, isReady, isLoading, isLoadingError, isCleanedUp } = injectLiveQuery(col) await waitForAngularUpdate() @@ -315,7 +315,7 @@ describe(`injectLiveQuery`, () => { await waitForAngularUpdate() expect(status()).toBe(`error`) - expect(isError()).toBe(true) + expect(isLoadingError()).toBe(true) col.__setStatus(`cleaned-up`) await waitForAngularUpdate() @@ -1129,7 +1129,7 @@ describe(`injectLiveQuery`, () => { expect(result.isReady()).toBe(true) expect(result.isLoading()).toBe(false) expect(result.isIdle()).toBe(false) - expect(result.isError()).toBe(false) + expect(result.isLoadingError()).toBe(false) expect(result.collection()).toBeNull() expect(result.data()).toEqual([]) expect(result.state().size).toBe(0) @@ -1181,7 +1181,7 @@ describe(`injectLiveQuery`, () => { expect(result.isReady()).toBe(true) expect(result.isLoading()).toBe(false) expect(result.isIdle()).toBe(false) - expect(result.isError()).toBe(false) + expect(result.isLoadingError()).toBe(false) expect(result.collection()).toBeNull() expect(result.data()).toEqual([]) expect(result.state().size).toBe(0) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index ca318b168b..10b72af469 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -948,7 +948,7 @@ type SyncControlFns = { commit: ((signal?: AbortSignal) => SyncAppliedReceipt) | null truncate: ((options?: { markReady?: boolean }) => void) | null metadata: SyncMetadataApi | null - markError: ((error: unknown) => void) | null + fail: ((error: unknown) => void) | null } /** @@ -1551,7 +1551,7 @@ class PersistedCollectionRuntime< commit: null, truncate: null, metadata: null, - markError: null, + fail: null, } private startupMetadataPromise: Promise | null = null private startPromise: Promise | null = null @@ -1867,26 +1867,26 @@ class PersistedCollectionRuntime< /** * Reports one failure for the current sync run. The wrapped sync adapter's - * own markError keeps core's meaning: final before Collection readiness, a - * final health error after it. A failure raised by persistence itself means + * own fail() keeps core's meaning: a failed first load before Collection + * readiness, a stopped sync with the rows kept after it. A failure raised by persistence itself means * the Collection can no longer serve reads or admit writes, so it crashes. */ reportSyncError( error: unknown, origin: `persistence` | `sync adapter` = `persistence`, ): unknown { - const markError = this.syncControls.markError + const fail = this.syncControls.fail if (this.syncErrorReported) return this.reportedSyncError - if (!markError) return error + if (!fail) return error - // After readiness the sync adapter's markError is a final health error - // and the run continues, so only a failure that ends the run latches. + // After readiness the sync adapter's fail() keeps the rows and the run + // continues, so only a failure that ends the run latches. if (origin === `persistence` || this.collection?.status !== `ready`) { this.syncErrorReported = true this.reportedSyncError = error } try { - if (origin === `sync adapter`) markError(error) + if (origin === `sync adapter`) fail(error) else this.collection?._lifecycle.crash(error) } catch { // Reporting must not replace the original asynchronous failure. @@ -1965,7 +1965,7 @@ class PersistedCollectionRuntime< commit: null, truncate: null, metadata: null, - markError: null, + fail: null, } } @@ -5834,7 +5834,7 @@ function createWrappedSyncConfig< commit: params.commit, truncate: params.truncate, metadata: params.metadata ?? null, - markError: params.markError, + fail: params.fail, }, ) @@ -5894,7 +5894,7 @@ function createWrappedSyncConfig< } let sourceSession = 0 - // Upstream health waits for persisted startup, as markReady does. + // Upstream reports wait for persisted startup, as markReady does. const afterStartup = (report: () => void) => { if (startupState.cleanedUp || getTerminalFailure()) return void (fullStartPromise ?? runtime.ensureStarted()) @@ -5905,7 +5905,7 @@ function createWrappedSyncConfig< } const wrappedParams = { ...params, - markError: reportUpstreamError, + fail: reportUpstreamError, reportError: (error: unknown) => afterStartup(() => params.reportError(error)), reportHealthy: () => afterStartup(params.reportHealthy), @@ -6345,8 +6345,8 @@ function createWrappedSyncConfig< markReady: () => { if (active()) wrappedParams.markReady() }, - markError: (error?: unknown) => { - if (active()) wrappedParams.markError(error) + fail: (error: unknown) => { + if (active()) wrappedParams.fail(error) }, begin: () => { if (active()) wrappedParams.begin() @@ -6742,7 +6742,7 @@ function createLoopbackSyncConfig< commit: params.commit, truncate: params.truncate, metadata: params.metadata ?? null, - markError: params.markError, + fail: params.fail, }, ) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 68e550fd19..c2fb6ab58b 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -2732,8 +2732,8 @@ it.each([`unknown`, `remote-durability`, `local-durability`] as const)( durableCursor: adapter.collectionMetadata.get(`probe:cursor`), durableTxCount: adapter.applyCommittedTxCalls.length, }).toEqual({ - sourceStatus: `error`, - liveStatus: `error`, + sourceStatus: `crashed`, + liveStatus: `crashed`, syncRuns: 1, durableIds: [], durableCursor: undefined, @@ -2886,7 +2886,7 @@ async function runRejectedHydrationBufferWitness( durableRows: rows.filter((row) => adapter.rows.has(row.id)), durabilityCalls: adapter.applyCommittedTxCalls.length, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, receiptOutcomes: rows.map(() => ({ status: `rejected`, @@ -3021,7 +3021,7 @@ function expectAbortGraphObservation( predecessorName: `AbortError`, successorStatus: `rejected`, sameFailure: true, - status: `error`, + status: `crashed`, publicSuccessor: undefined, durableSuccessor: undefined, pendingMarkers: 0, @@ -3073,7 +3073,7 @@ function expectOpenTransactionBoundaryObservation( ? `cleaned-up` : boundary === `abort` ? `ready` - : `error`, + : `crashed`, pendingMarkers: 0, }) } @@ -4230,7 +4230,7 @@ describeDurabilityOracles(`generated persistence durability oracles`, () => { predecessorName: `AbortError`, successorStatus: `rejected`, sameFailure: true, - status: `error`, + status: `crashed`, publicSuccessor: undefined, durableSuccessor: undefined, pendingMarkers: 0, @@ -4252,7 +4252,7 @@ describeDurabilityOracles(`generated persistence durability oracles`, () => { name: `open-transaction boundary leaks one core marker`, reject: () => expectOpenTransactionBoundaryObservation( - { status: `error`, pendingMarkers: 1 }, + { status: `crashed`, pendingMarkers: 1 }, `terminal-failure`, ), }, @@ -4424,7 +4424,7 @@ async function createTerminalFailureHarness( ).toBe(adapterError) } - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(terminalError) return { @@ -6131,8 +6131,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } }) - // A sync adapter that gives up after Collection readiness reports a final - // health error, and the sync run continues. A later persistence failure + // A sync adapter that gives up after Collection readiness sets the + // Collection's `crashed`, and the sync run continues. A later persistence failure // must still crash the Collection, so the first-error latch may hold only // failures that end the sync run. it.each([false, true])( @@ -6163,9 +6163,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { getKey: (todo) => todo.id, syncMode: `on-demand`, sync: { - sync: ({ markReady, markError }) => { + sync: ({ markReady, fail }) => { markReady() - giveUp = markError + giveUp = fail return { loadSubset: () => true, unloadSubset: sourceUnload, @@ -6192,7 +6192,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { giveUp!(gaveUp) await flushAsyncWork() expect(collection.status).toBe(`ready`) - expect(collection.health.error?.cause).toBe(gaveUp) + expect(collection.errors.error?.cause).toBe(gaveUp) } collection._sync.unloadSubset(failing) collection._sync.unloadSubset(sibling) @@ -6209,7 +6209,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { lifecycleErrors: crash.mock.calls.map(([error]) => error), unhandled, }).toEqual({ - status: `error`, + status: `crashed`, error: unloadError, unloadThenCalls: 1, unloadOptions: [failing, sibling], @@ -7009,7 +7009,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { receiptErrorCode: `SQLITE_FULL`, receiptErrorPath: `adapter.applyCommittedTx`, publicErrorIsReceipt: true, - status: `error`, + status: `crashed`, visible: { id: `failed`, title: `Not durable` }, durable: undefined, }) @@ -7098,7 +7098,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { cause: persistenceError, }) expect(adapter.rows.get(`failed`)).toBeUndefined() - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(rejection) } finally { rejectPersistence?.(persistenceError) @@ -7161,7 +7161,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { cause: persistenceResponse, }) expect(String(rejection)).not.toContain(`CONFLICT`) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(rejection) } finally { await collection.cleanup() @@ -7384,7 +7384,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { namedDurability: true, cause: storageError, exactPublicError: true, - status: `error`, + status: `crashed`, durable: undefined, }) } catch (error) { @@ -7529,7 +7529,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { firstStatus: `rejected`, siblingStatus: `rejected`, sameTerminal: true, - status: `error`, + status: `crashed`, publicErrorIsFirst: true, visibleSibling: undefined, durableSibling: undefined, @@ -7707,7 +7707,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { sameSibling: true, sameLoad: true, namedDurability: true, - status: `error`, + status: `crashed`, publicError: terminalError, publicSibling: undefined, applyCalls: 1, @@ -7850,7 +7850,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { abortedName: `AbortError`, dependentStatus: `rejected`, dependentExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleDependent: undefined, durableDependent: undefined, @@ -9855,7 +9855,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { status: collection.status, exactPublicError: collection._lifecycle.getSyncError() === receiptError, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -9939,7 +9939,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { status: collection.status, exactPublicError: collection._lifecycle.getSyncError() === receiptError, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10042,7 +10042,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }).toEqual({ transactionStatus: `rejected`, transactionExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10429,7 +10429,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { errorName: `PersistedCollectionDurabilityError`, cause: adapterError, publicErrorIsReceipt: true, - status: `error`, + status: `crashed`, firstVisible: { id: `first`, title: `published before persistence`, @@ -10466,7 +10466,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { receiptStatus: `rejected`, exactReceipt: true, publicErrorIsTerminal: true, - status: `error`, + status: `crashed`, lateVisible: undefined, lateDurable: undefined, applyCalls: 1, @@ -10570,7 +10570,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { collectionMetadata: `collection-metadata-before-failure`, applyCalls: applyCallsBefore, publishCalls: publishCallsBefore, - status: `error`, + status: `crashed`, exactPublicError: true, }) } @@ -10645,7 +10645,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { publishCalls: publishCallsBefore, visibleLate: undefined, durableLate: undefined, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10788,7 +10788,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { secondVisible: undefined, secondDurable: undefined, publishCalls: 0, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10926,7 +10926,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { forceVisible: undefined, lateStatus: `rejected`, lateErrorName: `CollectionStateError`, - status: `error`, + status: `crashed`, exactPublicError: true, applyCalls: 1, firstVisible: undefined, @@ -10967,7 +10967,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { `loopback hydration rejected`, ), ).rejects.toBe(hydrationError) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(hydrationError) restoreBaselineRows() @@ -11039,7 +11039,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { directExact: true, localStatus: `rejected`, localErrorName: `CollectionStateError`, - status: `error`, + status: `crashed`, exactPublicError: true, applyCalls: 0, directVisible: undefined, @@ -11161,7 +11161,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { path: `todos.sqlite`, cause: persistenceError, }) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(rejection) } finally { releaseHydration?.() @@ -12566,7 +12566,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { readyExact: true, lateStatus: `rejected`, lateExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleLate: undefined, durableLate: undefined, @@ -12628,7 +12628,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }).toEqual({ startupStatus: `rejected`, startupExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleRows: [], durableRows: @@ -12739,7 +12739,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { readyExact: true, lateStatus: `rejected`, lateExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleBad: undefined, visibleLate: undefined, @@ -14050,7 +14050,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { sameTerminal: true, durabilityCalls: 1, suffixDurable: undefined, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -14332,7 +14332,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { abortedName: `AbortError`, dependentStatus: `rejected`, dependentExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, publicRow: undefined, durableRow: undefined, @@ -14450,7 +14450,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { expect({ status: collection.status, pendingMarkers: collection._state.pendingSyncedTransactions.length, - }).toEqual({ status: `error`, pendingMarkers: 0 }) + }).toEqual({ status: `crashed`, pendingMarkers: 0 }) } catch (error) { hasPrimaryFailure = true throw error @@ -14732,7 +14732,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { let hasPrimaryFailure = false try { await expect(collection.stateWhenReady()).rejects.toBe(startupError) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(startupError) } catch (error) { hasPrimaryFailure = true @@ -18345,7 +18345,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }), }, }) - const unsubscribeError = collection.on(`status:error`, () => { + const unsubscribeError = collection.on(`status:crashed`, () => { terminal.resolve() }) let hasPrimaryFailure = false @@ -18420,7 +18420,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { loadCalls, }).toEqual({ outcome: `terminal`, - status: `error`, + status: `crashed`, publicError: receiptError, loadCalls: 0, }) @@ -18500,7 +18500,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }), }, }) - const unsubscribeError = collection.on(`status:error`, () => { + const unsubscribeError = collection.on(`status:crashed`, () => { terminal.resolve() }) let hasPrimaryFailure = false @@ -18574,7 +18574,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { loadCalls, }).toEqual({ outcome: `terminal`, - status: `error`, + status: `crashed`, publicError: loadError, loadCalls: 1, }) @@ -19556,7 +19556,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { expect(reacquired).not.toBe(true) await expect(Promise.resolve(reacquired)).rejects.toBe(failure) expect(adapter.loadSubsetCalls).toHaveLength(readsBeforeReacquire) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(failure) } finally { warn.mockRestore() @@ -19917,7 +19917,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { durableRow: adapter.rows.get(`shared`), durabilityCalls: adapter.applyCommittedTxCalls.length, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, publicRow: undefined, durableRow: { @@ -20984,7 +20984,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), ).rejects.toBe(hydrationFailure) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(hydrationFailure) expect([...collection.values()]).toEqual([]) expect(adapter.rows.size).toBe(0) @@ -21071,7 +21071,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { await vi.waitFor(() => expect(collection._lifecycle.getSyncError()).toBe(hydrationFailure), ) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) let laterAdapterCalls = 0 adapter.loadSubset = () => { @@ -21172,7 +21172,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { await vi.waitFor(() => expect(collection._lifecycle.getSyncError()).toBe(hydrationFailure), ) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(hydrationFailure) remoteBegin!() @@ -21203,7 +21203,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { receiptStatus: `rejected`, exactReceipt: true, publicErrorIsHydration: true, - status: `error`, + status: `crashed`, lateVisible: undefined, lateDurable: undefined, applyCalls: 0, @@ -22149,7 +22149,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { expect(collection.status).toBe(`ready`) expect(ensure).toHaveBeenCalledTimes(1) } else { - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) await expect( Promise.resolve().then(async () => { await collection._sync.loadSubset(options) @@ -22278,7 +22278,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // Recording-adapter I/O is immediate. Drain the turn while transport // remains held; this is not a real-host timing or latency assertion. await flushAsyncWork() - expect(collection.status).toBe(terminal ? `error` : `ready`) + expect(collection.status).toBe(terminal ? `crashed` : `ready`) if (terminal) { await atPersistedOracleCheckpoint( errorReported.promise, @@ -22751,7 +22751,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { path: [`database`, `wal`], cause: persistenceResponse, }) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(rejection) } else { expect(rejection).toMatchObject({ @@ -23885,7 +23885,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { expect(currentStorageId).toBe(`startup-rejected-new`) } else { await expect(ready).rejects.toBe(ioFailure) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(sourceStarts).toBe(0) expect(rotations).toBe(0) } @@ -25885,7 +25885,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { if (ownerRead === `fails`) { // Not settled(): a terminal runtime rejects the checkpoint scan. await flushAsyncWork() - await vi.waitFor(() => expect(collection.status).toBe(`error`)) + await vi.waitFor(() => expect(collection.status).toBe(`crashed`)) expectExactSnapshot(publicRows(), visible) } else { await settled(`owner update processed`) @@ -26079,7 +26079,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { history.consumer === `live-query` ? applied ? { outcome: `fulfilled`, rows: [`remote`], status: `ready` } - : { outcome: `rejected`, rows: undefined, status: `error` } + : { outcome: `rejected`, rows: undefined, status: `crashed` } : applied ? { outcome: `fulfilled`, @@ -26090,7 +26090,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { : { outcome: `rejected`, rows: undefined, - status: `error`, + status: `crashed`, releases: 1, }, } @@ -26411,7 +26411,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { : history.consumer === `live-query` && expected.settled.status === `ready` ? query.status - : expected.settled.status === `error` + : expected.settled.status === `crashed` ? history.consumer === `live-query` ? `${query.status}/${tab.source.status}` : tab.source.status @@ -26423,9 +26423,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ? { releases: expected.settled.releases } : {}), status: - expected.settled.status === `error` && + expected.settled.status === `crashed` && history.consumer === `live-query` - ? `error/error` + ? `crashed/crashed` : expected.settled.status, }) } finally { @@ -26510,7 +26510,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { releases: tab.releases(), ensures: tab.ensures(), rows: Array.from(tab.source.values()).map((row) => row.id), - status: tab.source.status === `error` ? `error` : `healthy`, + status: tab.source.status === `crashed` ? `crashed` : `healthy`, }).toEqual({ outcome: `rejected`, releases: 0, @@ -26638,7 +26638,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { rows: Record<`a` | `b` | `c`, number | undefined> // The new demand's load, when the history has one. newLoad?: `pending` | `fulfilled` | `rejected` - status: `error` | `healthy` + status: `crashed` | `healthy` } const expectedReacquisition = ( history: RotatedReacquisitionHistory, @@ -26669,7 +26669,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { : (`rejected` as const), } : {}), - status: failed ? `error` : `healthy`, + status: failed ? `crashed` : `healthy`, } } @@ -26863,7 +26863,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { c: source.get(`c-row`)?.v, }, ...(history.newDemand ? { newLoad } : {}), - status: source.status === `error` ? `error` : `healthy`, + status: source.status === `crashed` ? `crashed` : `healthy`, }) const turn = () => new Promise((resolve) => setTimeout(resolve, 0)) const settle = async () => { @@ -27541,12 +27541,12 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { expect( persistenceCapability?.resumeSnapshot.getKeySetEvidence(), ).toEqual({ status: `incompatible` }) - expect(collection.status).not.toBe(`error`) + expect(collection.status).not.toBe(`crashed`) } else { expect(outcome).toEqual([{ status: `rejected`, reason: ioFailure }]) expect(rotations).toBe(0) expect(storageId).toBe(oldStorageId) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) } } finally { releaseRead.resolve() @@ -27659,14 +27659,14 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { await flushAsyncWork() if (failure === `io`) { - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection.get(`old`)?.title).toBe(`Initial row`) expect(rotations).toBe(0) await expect(collection._sync.loadSubset({})).rejects.toBe(readError) return } expect(collection.get(`old`)?.title).not.toBe(`Expired peer row`) - expect(collection.status).not.toBe(`error`) + expect(collection.status).not.toBe(`crashed`) await vi.waitFor(() => expect(rotations).toBe(1)) expect(collection.get(`old`)).toBeUndefined() } finally { @@ -28658,7 +28658,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { queuedExact: true, lateStatus: `rejected`, lateExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleSeed: undefined, durableSeed: seed, @@ -28812,7 +28812,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { reloadVisible: undefined, targetedDurable: undefined, reloadDurable: undefined, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -30055,7 +30055,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } await entered.promise await flushAsyncWork() - expect(collection.status).not.toBe(`error`) + expect(collection.status).not.toBe(`crashed`) if (phase === `runtime`) { expect(collection.status).toBe(`ready`) expect(stripVirtualProps(collection.get(row.id))).toEqual(row) @@ -31675,7 +31675,7 @@ describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { status: `rejected`, reason: rejected.reason, }) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(rejected.reason) expect((rejected.reason as Error).cause).toBe(storageError) await expect( diff --git a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts index aa8633468b..0275831056 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts @@ -841,8 +841,8 @@ describe(`persisted-readiness oracle`, () => { networkTimeoutMs: 1_000, }, sync: { - sync: ({ markError }) => { - markNetworkError = markError + sync: ({ fail }) => { + markNetworkError = fail return {} }, }, @@ -989,7 +989,7 @@ describe(`persisted-readiness oracle`, () => { ]) }) - for (const failureMode of [`markError`, `throw`] as const) { + for (const failureMode of [`fail`, `throw`] as const) { it(`publishes restored query rows when ${failureMode} precedes the persisted read`, async () => { const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) const networkFailure = new Error(`network failed before restore`) @@ -1005,10 +1005,10 @@ describe(`persisted-readiness oracle`, () => { networkTimeoutMs: 1_000, }, sync: { - sync: ({ markError }) => { + sync: ({ fail }) => { networkSyncStarted = true if (failureMode === `throw`) throw networkFailure - markNetworkError = markError + markNetworkError = fail return {} }, }, @@ -1022,7 +1022,7 @@ describe(`persisted-readiness oracle`, () => { await checkWithCleanup(async () => { const initialRenderWait = observer.preloadForInitialRender() await vi.waitFor(() => expect(networkSyncStarted).toBe(true)) - if (failureMode === `markError`) markNetworkError(networkFailure) + if (failureMode === `fail`) markNetworkError(networkFailure) expect(observer.getSnapshot()).toMatchObject({ persistedStatus: `loading`, data: [], diff --git a/packages/db/e2e/indexed-db/browser.ts b/packages/db/e2e/indexed-db/browser.ts index 91d54a5ea9..c142ed49d6 100644 --- a/packages/db/e2e/indexed-db/browser.ts +++ b/packages/db/e2e/indexed-db/browser.ts @@ -263,10 +263,7 @@ async function setup() { tab, rows: snapshot(collection.values()), status: collection.status, - health: collection.health.error && { - phase: collection.health.error.phase, - final: collection.health.error.final, - }, + syncError: collection.errors.error?.message ?? null, callbacks: { ...callbacks }, reads: [...reads], writes: [...nativeWrites], diff --git a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts index 0b6cc7a52e..7a0369bcb4 100644 --- a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts @@ -570,11 +570,11 @@ for (const obsolete of [false, true]) const state = await observe(receiver) expect(state.reads).toContain('abort') // After Collection readiness, an active receiver that misses a peer - // change gives up as a final health error; it keeps its rows and status. + // change stops syncing: the Collection's `error`, with its rows and + // status kept. expect(state.status).toBe('ready') - expect(state.health).toEqual( - obsolete ? null : { phase: 'sync', final: true }, - ) + if (obsolete) expect(state.syncError).toBeNull() + else expect(state.syncError).toEqual(expect.any(String)) if (obsolete) assertStatuses( state.statuses, diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 0bfc6f5d2f..ef310a27be 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -157,7 +157,6 @@ "createSubsetAcquisitionRecord": "cF", "cancelRepairRetry": "cG", "getVisibleEntries": "cH", - "transitionToError": "cI", "baselineWindow": "cJ", "enrichChangeWithVirtualProps": "cK", "hydrationError": "cL", @@ -298,7 +297,6 @@ "publishEvents": "e5", "releaseDemand": "e6", "rollbackLease": "e7", - "setErrorState": "e8", "trackSentKeys": "e9", "updateTimeout": "fa", "flushTruncateReplay": "fb", @@ -400,17 +398,11 @@ "restore": "gE", "attach": "gF", "sortedExactValues": "gG", - "sourceProblem": "bI", "reachedReady": "cc", "clearFailedRequest": "dp", - "initialProblem": "eN", "requestErrors": "gH", - "recordSourceProblem": "gI", "reportFailedRequest": "gJ", - "assertReportable": "gK", "includeSourceIds": "gL", - "reportHealth": "gM", - "onInstanceHealthChange": "gN", "latchReady": "gO", "bucketFacades": "gP", "reportedTree": "gQ", @@ -418,5 +410,15 @@ "endedProblem": "gS", "errorTree": "gT", "failLoad": "gU", - "settleWhenLoaded": "gV" + "settleWhenLoaded": "gV", + "assertAdapterActive": "bI", + "statusError": "cI", + "stoppedError": "eN", + "onInstanceErrorsChange": "e8", + "statusAllowsError": "gI", + "reportErrors": "gK", + "enterErrorState": "gM", + "crashQuery": "gN", + "failQuery": "gW", + "assertReady": "gX" } diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index aa24cffdd0..3c0ae33c02 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -1,5 +1,5 @@ import { getCollectionBuilder } from '../query/live/collection-registry.js' -import type { CollectionErrorInfo, ErrorTreeNode } from '../types' +import type { ErrorTreeNode } from '../types' import type { CollectionImpl } from './index.js' /** A Collection that another Collection reads, under its query alias. */ @@ -38,9 +38,7 @@ export function buildErrorTree( // A live query's tree shape comes from its query definition. const builder = getCollectionBuilder(collection) const sources = builder?.errorTreeSources() ?? [] - const lifecycle = collection._lifecycle - const error: CollectionErrorInfo | null = - lifecycle.gateProblem ?? lifecycle.health.error + const { failureReason, error } = collection._lifecycle.problems const children = sources.map((source) => buildErrorTree(source.collection, source, collection), ) @@ -49,6 +47,7 @@ export function buildErrorTree( collectionId: collection.id, kind: kind ?? (builder ? `live-query` : `source`), status: collection.status, + failureReason, error, ...(instances ? { instances: countInstances(instances()) } : {}), sources: children, @@ -56,33 +55,41 @@ export function buildErrorTree( } /** - * A node from its own facts and its children. The node is in error when it - * has a problem, and has errors when it or anything below it does. + * A node from its own facts and its children. It has errors when it or + * anything below it has a `failureReason` or an `error`. */ export function errorTreeNode( owner: object, id: string | undefined, - fields: Omit, + fields: Omit, ): ErrorTreeNode { - const { error, instances, sources } = fields + const { failureReason, error, instances, sources } = fields return cachedNode(owner, id ?? fields.alias, { ...fields, - isError: error !== null, hasErrors: + failureReason !== null || error !== null || - (instances?.inError ?? 0) > 0 || + (instances?.withErrors ?? 0) > 0 || sources.some((child) => child.hasErrors), }) } +/** Whether a Collection's own node shows a problem. */ +export function hasOwnProblem( + collection: Pick, `_lifecycle`>, +): boolean { + const { failureReason, error } = collection._lifecycle.problems + return failureReason !== null || error !== null +} + function countInstances( collections: ReadonlyArray>, -): { total: number; inError: number } { - let inError = 0 +): { total: number; withErrors: number } { + let withErrors = 0 for (const instance of collections) { - if (instance.status === `error` || instance.health.isError) inError++ + if (hasOwnProblem(instance)) withErrors++ } - return { total: collections.length, inError } + return { total: collections.length, withErrors } } /** Return the cached node for this Collection and alias if nothing changed. */ @@ -104,11 +111,11 @@ function sameNode(a: ErrorTreeNode, b: ErrorTreeNode): boolean { a.collectionId === b.collectionId && a.kind === b.kind && a.status === b.status && - a.isError === b.isError && a.hasErrors === b.hasErrors && + a.failureReason === b.failureReason && a.error === b.error && a.instances?.total === b.instances?.total && - a.instances?.inError === b.instances?.inError && + a.instances?.withErrors === b.instances?.withErrors && a.sources.length === b.sources.length && a.sources.every((source, index) => source === b.sources[index]) ) diff --git a/packages/db/src/collection/events.ts b/packages/db/src/collection/events.ts index a7f16a2332..f3ea096de9 100644 --- a/packages/db/src/collection/events.ts +++ b/packages/db/src/collection/events.ts @@ -46,11 +46,11 @@ export interface CollectionLoadingSubsetChangeEvent { /** * Event emitted when the Collection's error tree may have changed: its own - * health, or for a live query, the status or health of a Collection it - * depends on. Read `collection.errors` for the current tree. + * status or problems, or for a live query, those of a Collection it depends + * on. Read `collection.errors` for the current tree. */ -export interface CollectionHealthChangeEvent { - type: `health:change` +export interface CollectionErrorsChangeEvent { + type: `errors:change` collection: Collection } @@ -111,7 +111,7 @@ export type AllCollectionEvents = { 'status:change': CollectionStatusChangeEvent 'subscribers:change': CollectionSubscribersChangeEvent 'loadingSubset:change': CollectionLoadingSubsetChangeEvent - 'health:change': CollectionHealthChangeEvent + 'errors:change': CollectionErrorsChangeEvent truncate: CollectionTruncateEvent 'index:added': CollectionIndexAddedEvent 'index:removed': CollectionIndexRemovedEvent @@ -176,9 +176,9 @@ export class CollectionEventsManager extends EventEmitter { ) } - emitHealthChange() { - this.emit(`health:change`, { - type: `health:change`, + emitErrorsChange() { + this.emit(`errors:change`, { + type: `errors:change`, collection: this.collection, }) } diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 1cf5c1293d..f503066fbc 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -31,7 +31,6 @@ import type { IndexOptions } from '../indexes/index-options.js' import type { ChangeMessage, CollectionConfig, - CollectionHealth, CollectionStatus, CurrentStateAsChangesOptions, ErrorTreeNode, @@ -557,25 +556,19 @@ export class CollectionImpl< return this._lifecycle.status } - /** - * The Collection's health after its first ready. `status` only gates the - * initial load; a later sync problem appears here and the rows stay. - */ - public get health(): CollectionHealth { - return this._lifecycle.health - } - /** * The error tree for this Collection and every Collection it depends on. - * It always has the same shape for a given query, so apps can parse it. + * A base Collection's tree is one node. It always has the same shape for a + * given query, so apps can parse it. After the first ready, sync problems + * appear here and the rows stay. */ public get errors(): ErrorTreeNode { return buildErrorTree(this) } /** @internal A Collection this one depends on changed its error tree. */ - public _emitHealthChange(): void { - this._events.emitHealthChange() + public _emitErrorsChange(): void { + this._events.emitErrorsChange() } /** diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 667729488e..5029fdd015 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -16,7 +16,6 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' import type { CollectionConfig, CollectionErrorInfo, - CollectionHealth, CollectionStatus, } from '../types' import type { CollectionEventsManager } from './events' @@ -56,13 +55,15 @@ export class CollectionLifecycleManager< private readyFailureSink: ((failure: { error: unknown }) => void) | undefined public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null - // Health is metadata after Collection readiness. The sync adapter's own report and a - // live query's request failures are separate, so settling a request cannot - // clear the sync adapter's problem. - private sourceProblem: CollectionErrorInfo | null = null - // Error tree detail for the initial gate error, kept so readers see the - // same object until the sync run ends. - private initialProblem: CollectionErrorInfo | null = null + // After the first ready, the sync adapter's problem is error tree detail: + // `failureReason` while it still tries, `stoppedError` once it called fail(). + // A live query's request failures are kept apart, so settling a request + // cannot clear the sync adapter's problem. + private failureReason: CollectionErrorInfo | null = null + private stoppedError: CollectionErrorInfo | null = null + // The cause of status `error` or `crashed`, kept so readers see the same + // object until the sync run ends. + private statusError: CollectionErrorInfo | null = null private requestErrors = new RequestErrors() private cleanupConfig: () => void private statusRevision = 0 @@ -112,14 +113,16 @@ export class CollectionLifecycleManager< CollectionStatus, Array > = { - idle: [`loading`, `error`, `cleaned-up`], - loading: [`ready`, `error`, `cleaned-up`], - // A ready Collection moves to `error` only through crash(); a source's - // later failure is health metadata. - ready: [`cleaned-up`, `error`], - // An initial error is final for its sync run. - error: [`cleaned-up`, `idle`], - 'cleaned-up': [`loading`, `error`], + idle: [`loading`, `error`, `crashed`, `cleaned-up`], + loading: [`ready`, `error`, `crashed`, `cleaned-up`], + // After the first ready only a crash changes the status; a sync + // problem is error tree detail. + ready: [`cleaned-up`, `crashed`], + // A failed first load is final for its sync run. + error: [`cleaned-up`, `idle`, `crashed`], + // A crash is permanent for the Collection instance. + crashed: [`cleaned-up`], + 'cleaned-up': [`loading`, `error`, `crashed`], } if (!validTransitions[from].includes(to)) { @@ -157,7 +160,7 @@ export class CollectionLifecycleManager< this.events.emitStatusChange(newStatus, previousStatus, isCurrent) // The status is part of the error tree. status:change fires first, and // the tree is already current when either event fires. - if (isCurrent()) this.events.emitHealthChange() + if (isCurrent()) this.events.emitErrorsChange() } /** @@ -167,6 +170,7 @@ export class CollectionLifecycleManager< public validateCollectionUsable(operation: string): void { switch (this.status) { case `error`: + case `crashed`: throw new CollectionInErrorStateError(operation, this.id) case `cleaned-up`: // Automatically restart the collection when operations are called on cleaned-up collections @@ -185,6 +189,9 @@ export class CollectionLifecycleManager< * listeners and ready callbacks, so their writes follow it. */ public markReady(beforeEffects?: () => void): void { + // A crash ended the sync run from core's side; the adapter is not at fault. + if (this.status === `crashed`) return + if (this.stoppedError) this.assertAdapterActive(`markReady`) const failure = this.applyReadyTransition(beforeEffects) if (!failure) return if (this.readyFailureSink) this.readyFailureSink(failure) @@ -250,83 +257,111 @@ export class CollectionLifecycleManager< } /** - * The sync adapter gave up. Before Collection readiness this fails the - * initial sync of the sync run; after it, it is a final health error and - * the rows stay. + * The sync adapter stopped. Before the first ready this fails the sync + * run's first load; after it, the rows stay and the error shows until a + * reload. An earlier fail() already ended the adapter's work, so a second + * one is a contract violation. */ - public markError(error?: unknown): void { - if (this.status === `ready`) this.recordSourceProblem(error, true) - else this.crash(error) + public fail(error: unknown): void { + if (this.status === `crashed`) return + this.assertAdapterActive(`fail`) + if (this.status !== `ready`) { + this.validateStatusTransition(this.status, `error`) + this.statusError = describeProblem(error) + this.setStatus(`error`) + return + } + this.failureReason = null + this.stoppedError = describeProblem(error) + this.events.emitErrorsChange() } /** - * Move the Collection to `error` from any active status. Internal - * invariant failures use this even after ready, so a Collection that holds - * unpublished rows stops being usable. + * Local storage or an internal invariant broke. The Collection can no + * longer serve reads or accept writes, from any active status. A crash is + * permanent for this Collection instance. */ public crash(error?: unknown): void { - // The first error is final for the sync run. - if (this.status === `error`) return - this.validateStatusTransition(this.status, `error`) - this.initialProblem = describeProblem(error, `initial`, true) - this.setStatus(`error`) + if (this.status === `crashed`) return + this.validateStatusTransition(this.status, `crashed`) + this.statusError = describeProblem(error) + this.setStatus(`crashed`) } - public get health(): CollectionHealth { - const error = this.sourceProblem ?? this.requestErrors.problem - return { isError: error !== null, error } + /** The Collection's own problems, as error tree detail. */ + public get problems(): { + failureReason: CollectionErrorInfo | null + error: CollectionErrorInfo | null + } { + const error = this.statusAllowsError() + ? this.statusError + : (this.stoppedError ?? this.requestErrors.problem) + return { failureReason: error ? null : this.failureReason, error } } - /** The cause of the initial gate error, as error tree detail. */ - public get gateProblem(): CollectionErrorInfo | null { - return this.status === `error` ? this.initialProblem : null + /** The cause of status `error` or `crashed`, as error tree detail. */ + public get statusProblem(): CollectionErrorInfo | null { + return this.statusAllowsError() ? this.statusError : null } - /** The sync adapter still tries after a problem. Status and rows do not change. */ - public reportError(error: unknown): void { - this.assertReportable(`reportError`) - this.recordSourceProblem(error, false) + private statusAllowsError(): boolean { + return this.status === `error` || this.status === `crashed` } - private recordSourceProblem(error: unknown, final: boolean): void { - // A final problem stays until a restart. - if (this.sourceProblem?.final) return - this.sourceProblem = describeProblem(error, `sync`, final) - this.events.emitHealthChange() + /** The sync adapter still tries after a problem. Status and rows do not change. */ + public reportError(error: unknown): void { + if (this.status === `crashed`) return + this.assertAdapterActive(`reportError`) + this.assertReady(`reportError`) + this.failureReason = describeProblem(error) + this.events.emitErrorsChange() } - /** The source synced successfully again. A final problem stays. */ + /** The source synced successfully again. With nothing to clear it does nothing. */ public reportHealthy(): void { - this.assertReportable(`reportHealthy`) - if (!this.sourceProblem || this.sourceProblem.final) return - this.sourceProblem = null - this.events.emitHealthChange() + if (this.status === `crashed`) return + this.assertAdapterActive(`reportHealthy`) + this.assertReady(`reportHealthy`) + if (!this.failureReason) return + this.failureReason = null + this.events.emitErrorsChange() } /** @internal A live query's request failed after initial-query readiness. */ public reportRequestError(key: string, error: unknown): void { this.requestErrors.fail(key, error) - this.events.emitHealthChange() + this.events.emitErrorsChange() } /** @internal The failed request settled or was released. */ public clearRequestError(key: string): void { - if (this.requestErrors.settle(key)) this.events.emitHealthChange() + if (this.requestErrors.settle(key)) this.events.emitErrorsChange() + } + + /** fail() ends the sync adapter's reporting for its sync run. */ + private assertAdapterActive(operation: string): void { + if (this.status === `error` || this.stoppedError) { + throw new CollectionStateError( + devBuild() && process.env.NODE_ENV !== `production` + ? `${operation}() was called after fail() in the same sync run of Collection "${this.id}". fail() means the sync adapter stopped; a reload starts a new sync run.` + : codedMessage(247, { operation, id: this.id }), + ) + } } - private assertReportable(operation: string): void { + private assertReady(operation: string): void { if (this.status !== `ready`) { throw new CollectionStateError( devBuild() && process.env.NODE_ENV !== `production` - ? `${operation}() reports a problem after Collection readiness, but Collection "${this.id}" is ${this.status}. Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up.` + ? `${operation}() reports on sync after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call fail() when the sync adapter stops.` : codedMessage(245, { operation, id: this.id, status: this.status }), ) } } - /** Return the cause supplied by the current sync run, if any. */ + /** Return the cause of status `error` or `crashed` in this sync run, if any. */ public getSyncError(): unknown { - return this.initialProblem?.cause + return this.statusError?.cause } public assertCanStartSync(): void { @@ -561,10 +596,11 @@ export class CollectionLifecycleManager< CleanupQueue.getInstance().cancel(this) this.hasBeenReady = false - // Health belongs to the discarded attempt. - this.sourceProblem = null + // Problems belong to the discarded sync run. + this.failureReason = null + this.stoppedError = null this.requestErrors = new RequestErrors() - this.initialProblem = null + this.statusError = null // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. this.onFirstReadyCallbacks = [] @@ -615,7 +651,7 @@ export class RequestErrors { fail(key: string, error: unknown): void { this.failed.delete(key) - this.problem = describeProblem(error, `request`, false) + this.problem = describeProblem(error) this.failed.set(key, this.problem) } @@ -630,14 +666,8 @@ export class RequestErrors { } /** @internal Error tree detail for a problem value. */ -export function describeProblem( - error: unknown, - phase: CollectionErrorInfo[`phase`], - final: boolean, -): CollectionErrorInfo { +export function describeProblem(error: unknown): CollectionErrorInfo { const info = { - phase, - final, name: error instanceof Error ? error.name : `Error`, message: error instanceof Error diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 76fbbad68a..a51d129a1b 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -339,6 +339,7 @@ export class CollectionSubscription } if ( this.collection.status === `error` || + this.collection.status === `crashed` || this.collection._sync.syncLoadSubsetFn === null ) { this.setReadyIfIdle() @@ -1044,6 +1045,7 @@ export class CollectionSubscription // deferred starts still acquire through the sync manager's queue. (this.collection.config.syncMode === `on-demand` && (this.collection.status === `error` || + this.collection.status === `crashed` || (this.collection.status !== `idle` && this.collection._sync.syncLoadSubsetFn === null))) ) { diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 321310454b..6f776421e4 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -22,6 +22,7 @@ import type { ChangeMessageOrDeleteKeyMessage, CleanupFn, CollectionConfig, + CollectionStatus, LoadSubsetFn, LoadSubsetOptions, LoadSubsetRequestResult, @@ -320,8 +321,8 @@ export class CollectionSyncManager< // transaction holds still publish with the drop of its state. if (isCurrentSync()) this.lifecycle.markReady() }, - markError: (error?: unknown) => { - if (isCurrentSync()) this.lifecycle.markError(error) + fail: (error: unknown) => { + if (isCurrentSync()) this.lifecycle.fail(error) }, reportError: (error: unknown) => { if (isCurrentSync()) this.lifecycle.reportError(error) @@ -396,7 +397,10 @@ export class CollectionSyncManager< this.completePendingSyncEntryCleanup() finishReadyFailures() // A throwing sync function is a programming error, even after ready. - if (isCurrentSync()) this.lifecycle.crash(error) + // A first load that already failed in this run reported its error. + // The sync function can change the status before it throws. + const status = this.lifecycle.status as CollectionStatus + if (isCurrentSync() && status !== `error`) this.lifecycle.crash(error) throw error } if (readyFailure) throw readyFailure.error @@ -621,7 +625,10 @@ export class CollectionSyncManager< return } - if (this.lifecycle.status === `error`) { + if ( + this.lifecycle.status === `error` || + this.lifecycle.status === `crashed` + ) { reject(this.getPreloadError()) return } @@ -658,12 +665,18 @@ export class CollectionSyncManager< // when GC has already queued the destructive idle callback. this.lifecycle.cancelGCTimer() unsubscribeReady = this.lifecycle.onFirstReady(resolveReady) - unsubscribeError = this.collection.on(`status:error`, () => { + const onFailure = () => { if (syncStartState.active) { return } rejectError(this.getPreloadError()) - }) + } + const unsubscribeFailed = this.collection.on(`status:error`, onFailure) + const unsubscribeCrashed = this.collection.on(`status:crashed`, onFailure) + unsubscribeError = () => { + unsubscribeFailed() + unsubscribeCrashed() + } // Start sync if collection hasn't started yet or was cleaned up if ( @@ -679,7 +692,10 @@ export class CollectionSyncManager< } finally { syncStartState.active = false } - if (this.collection.status === `error`) { + if ( + this.collection.status === `error` || + this.collection.status === `crashed` + ) { rejectError(this.getPreloadError()) } else if (syncStartState.ready) { // A first-ready listener can throw after readiness is established. diff --git a/packages/db/src/indexed-db.ts b/packages/db/src/indexed-db.ts index 5e5538f52f..ba21fcd0d6 100644 --- a/packages/db/src/indexed-db.ts +++ b/packages/db/src/indexed-db.ts @@ -672,16 +672,24 @@ export function indexedDBCollectionOptions( } const internalSync: SyncConfig['sync'] = (params) => { - const { begin, write, commit, markError } = params + const { begin, write, commit } = params activeSync = params collection = params.collection let channel: BroadcastChannel | undefined + let stopped = false + // fail() ends this sync run's reporting, so stop listening for changes. + const fail = (error: unknown) => { + if (stopped || activeSync !== params) return + stopped = true + channel?.close() + params.fail(error) + } const retire = () => { channel?.close() if (activeSync === params) { broadcastChannel = undefined // Closure ends read and admission authority, even after ready, so the - // Collection retires instead of reporting health. + // Collection crashes instead of reporting a sync problem. params.collection._lifecycle.crash(connection?.error) } } @@ -757,16 +765,14 @@ export function indexedDBCollectionOptions( commit() } } catch (error) { - if (activeSync === params) markError(error) + fail(error) } } } catch { // Persistence also works in environments without BroadcastChannel. } - void restore(params, false).catch((error: unknown) => { - if (activeSync === params) markError(error) - }) + void restore(params, false).catch(fail) return { cleanup: () => { diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index 6979829a25..6932216745 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -59,7 +59,10 @@ export interface LiveQueryStatusFlags { isLoading: boolean isReady: boolean isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. `status` is `error`. */ + isLoadingError: boolean + /** Local storage or an invariant broke. `status` is `crashed`. */ + isCrashed: boolean isCleanedUp: boolean } @@ -75,7 +78,8 @@ export function getLiveQueryStatusFlags( isLoading: status === `loading`, isReady: status === `ready`, isIdle: status === `idle`, - isError: status === `error`, + isLoadingError: status === `error`, + isCrashed: status === `crashed`, isCleanedUp: status === `cleaned-up`, } } diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 1c07c1b66d..97d8c0598e 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -93,14 +93,21 @@ export interface LiveQuerySnapshot< isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean isCleanedUp: boolean isEnabled: boolean + /** + * The data is shown, and something keeps it from staying current: a sync + * problem or a failed request somewhere in `errors`. + */ + isSyncError: boolean /** * The error tree for the query and every Collection it depends on, or - * `undefined` for a disabled query. `isError` describes only the initial - * load; after the first ready, a source problem shows here and the data - * stays. + * `undefined` for a disabled query. After the first ready, a source + * problem shows here and the data stays. */ errors: ErrorTreeNode | undefined } @@ -189,9 +196,11 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot = { isPersistedReady: false, persistedError: undefined, isIdle: false, - isError: false, + isLoadingError: false, + isCrashed: false, isCleanedUp: false, isEnabled: false, + isSyncError: false, errors: undefined, } @@ -317,6 +326,7 @@ class LiveQueryObserverImpl< this.layoutRevision++ } + const errors = (collection as { errors?: ErrorTreeNode }).errors // Most consumers read only `data`; build the keyed view on first read. let state: Map | undefined this.cachedSnapshot = { @@ -332,7 +342,8 @@ class LiveQueryObserverImpl< isPersistedReady: persistedStatus === `ready`, persistedError: local.error, isEnabled: true, - errors: (collection as { errors?: ErrorTreeNode }).errors, + isSyncError: status === `ready` && errors?.hasErrors === true, + errors, } this.snapshotDirty = false } @@ -760,16 +771,16 @@ class LiveQueryObserverImpl< // any status change publishes a synthetic notify so consumers re-read the // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a // detached attachment leaves nothing behind. - // A health change below the query changes its error tree, not its rows. - // Each status change is followed by a health change; tree identity - // tells whether the health change has anything left to publish. + // An errors change below the query changes its error tree, not its rows. + // Each status change is followed by an errors change; tree identity + // tells whether the errors change has anything left to publish. const errorsOf = () => (collection as { errors?: ErrorTreeNode }).errors let publishedErrors = errorsOf() const statusUnsub = collection.on(`status:change`, ({ status }) => { publishedErrors = errorsOf() notify(undefined, status) }) - const healthUnsub = collection.on(`health:change`, () => { + const errorsUnsub = collection.on(`errors:change`, () => { if (errorsOf() === publishedErrors) return publishedErrors = errorsOf() // No rows or status changed, so only this marks the snapshot stale. @@ -831,7 +842,7 @@ class LiveQueryObserverImpl< const release = () => { clientUnsub() statusUnsub() - healthUnsub() + errorsUnsub() layoutUnsub() persistedUnsubs?.forEach((unsubscribe) => unsubscribe()) subscription?.unsubscribe() diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 308c40b7cd..a6fce044e9 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -528,9 +528,12 @@ export interface LiveQueryWindowSnapshot< isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + isLoadingError: boolean + isCrashed: boolean isCleanedUp: boolean isEnabled: boolean + /** The data is shown, and a sync problem or failed page load is in `errors`. */ + isSyncError: boolean /** The error tree for the collection and its dependencies, or `undefined` when disabled. */ errors: ErrorTreeNode | undefined } @@ -690,9 +693,11 @@ class LiveQueryWindowControllerImpl< isPersistedReady: observerSnapshot.isPersistedReady, persistedError: observerSnapshot.persistedError, isIdle: observerSnapshot.isIdle, - isError: observerSnapshot.isError, + isLoadingError: observerSnapshot.isLoadingError, + isCrashed: observerSnapshot.isCrashed, isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, + isSyncError: observerSnapshot.isSyncError, errors: observerSnapshot.errors, } this.cachedFrom = { diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 05980819a4..b60ca01743 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -134,18 +134,19 @@ export interface EffectConfig< onError?: (error: Error, event: DeltaEvent) => void /** - * Called when a Collection fails before the effect's initial-query readiness, or - * is cleaned up. The effect is automatically disposed after this callback - * fires. If not provided, the error is logged to console.error. + * Called when a source fails its first load before the effect's + * initial-query readiness, crashes, or is cleaned up. The effect is + * automatically disposed after this callback fires. If not provided, the + * error is logged to console.error. */ onSourceError?: (error: Error) => void /** - * Called after the effect's initial-query readiness whenever a Collection's status or health - * changes. A Collection health problem after initial-query readiness does not dispose the + * Called after the effect's initial-query readiness whenever its error tree + * changes. A sync problem after initial-query readiness does not dispose the * effect; read the error tree to decide what to do. */ - onHealthChange?: (errors: ErrorTreeNode) => void + onErrorsChange?: (errors: ErrorTreeNode) => void /** * Skip deltas during initial collection load. @@ -315,7 +316,7 @@ export function createEffect< query: config.query, skipInitial: config.skipInitial ?? false, onBatchProcessed, - onHealthChange: config.onHealthChange, + onErrorsChange: config.onErrorsChange, onSourceError: (error: Error) => { if (disposed) return @@ -386,7 +387,7 @@ interface EffectPipelineRunnerConfig< onBatchProcessed: (events: Array>) => void /** Called when a source collection enters error or cleaned-up state */ onSourceError: (error: Error) => void - onHealthChange?: (errors: ErrorTreeNode) => void + onErrorsChange?: (errors: ErrorTreeNode) => void } /** @@ -472,11 +473,11 @@ class EffectPipelineRunner { private initialLoadComplete = false // The Effect's initial gate: latched when every // initial load has settled, with or without skipInitial. After it, - // a Collection health problem does not dispose the Effect. + // a sync problem does not dispose the Effect. private reachedReady = false private readonly requests = new RequestErrors() private reportedTree: ErrorTreeNode | undefined - private readonly onHealthChange: ((errors: ErrorTreeNode) => void) | undefined + private readonly onErrorsChange: ((errors: ErrorTreeNode) => void) | undefined // Scheduler integration private subscribedToAllCollections = false @@ -496,7 +497,7 @@ class EffectPipelineRunner { this.skipInitial = config.skipInitial this.onBatchProcessed = config.onBatchProcessed this.onSourceError = config.onSourceError - this.onHealthChange = config.onHealthChange + this.onErrorsChange = config.onErrorsChange // Parse query this.query = buildQueryFromConfig({ query: config.query }) @@ -739,14 +740,17 @@ class EffectPipelineRunner { const { status } = event - // A source in error ends the effect, before or after its gate: an - // initial error or a crash. Health errors never change the status. - if (status === `error`) { + // A crash ends the effect at any time. A failed first load ends it + // before its gate; after the gate it is a sync problem in the tree. + if ( + status === `crashed` || + (status === `error` && !this.reachedReady) + ) { this.onSourceError( new Error( devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' entered error state` - : codedMessage(135, { collectionId }), + ? `Source collection '${collectionId}' entered ${status} state` + : codedMessage(135, { collectionId, status }), ), ) return @@ -766,12 +770,12 @@ class EffectPipelineRunner { ) { this.initialLoadComplete = true } - // health:change follows every status change and reports the tree. + // errors:change follows every status change and reports the tree. this.latchReady() }) this.unsubscribeCallbacks.add(statusUnsubscribe) this.unsubscribeCallbacks.add( - collection.on(`health:change`, () => this.reportHealth()), + collection.on(`errors:change`, () => this.reportErrors()), ) const cleanupStartUnsubscribe = collection._onCleanupStart(handleSourceCleanup) @@ -1078,7 +1082,7 @@ class EffectPipelineRunner { private failLoad(sourceId: string, error: Error): void { if (!this.reachedReady) return this.onSourceError(error) this.requests.fail(sourceId, error) - this.reportHealth() + this.reportErrors() } /** A load that later succeeds clears the source's request error. */ @@ -1090,7 +1094,7 @@ class EffectPipelineRunner { } private settleLoad(sourceId: string): void { - if (this.requests.settle(sourceId)) this.reportHealth() + if (this.requests.settle(sourceId)) this.reportErrors() } /** The error tree for this effect's sources, in query order. */ @@ -1100,6 +1104,7 @@ class EffectPipelineRunner { collectionId: `effect`, kind: `live-query`, status: this.reachedReady ? `ready` : `loading`, + failureReason: null, error: this.requests.problem, sources: this.collectionSources.map(({ sourceId, alias, collection }) => buildErrorTree(collection, { alias, id: sourceId }, this), @@ -1107,13 +1112,13 @@ class EffectPipelineRunner { }) } - private reportHealth(): void { - if (this.disposed || !this.reachedReady || !this.onHealthChange) return + private reportErrors(): void { + if (this.disposed || !this.reachedReady || !this.onErrorsChange) return // An unchanged tree keeps its identity; report each change once. const tree = this.errorTree() if (tree === this.reportedTree) return this.reportedTree = tree - this.onHealthChange(tree) + this.onErrorsChange(tree) } private canCompleteInitialLoad(): boolean { diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 6c0ec4a0d0..4d6401660a 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -779,8 +779,8 @@ source rows belong in a query result. Collection cleanup has two boundaries. Cleanup start is an internal synchronous boundary. It closes restart admission, invalidates the current sync run, detaches -its demand, puts dependent live queries in terminal error, and marks dependent -Effects disposed. Both dependent transitions happen before adapter cleanup +its demand, crashes dependent live queries, and marks dependent Effects +disposed. Both dependent transitions happen before adapter cleanup settles. This prevents either dependent from using work that belongs to the discarded sync run. The Effect becomes disposed and releases its source subscription synchronously. Its disposal promise may still wait for in-flight @@ -818,8 +818,8 @@ fresh private publication barrier. Settlements from the old sync run cannot publish rows, report errors, or change readiness in the new sync run. This is the direct subscription's restart contract, not automatic recovery of -a dependent live query. Manually cleaning up a source puts its live queries in -a terminal error state. Restarting that source alone does not revive their +a dependent live query. Manually cleaning up a source crashes its live +queries. Restarting that source alone does not revive their graphs or publish replacement results; callers must restart or recreate the live query itself. This differs from a source truncate, which keeps the live query active behind its replay publication barrier. @@ -1340,7 +1340,7 @@ delivered, so with it the gate also ends initial suppression. An obsolete or aborted initial load releases its hold, and a truncate replacement takes the hold over. Neither can keep the gate open or close it for good. A real load failure before the gate disposes the Effect; after the gate, a source problem -is health, delivered through `onHealthChange`. +is error tree detail, delivered through `onErrorsChange`. Two limits remain. Before an on-demand source completes a full sync, a ready query can hold rows that the source does not yet update. The source contract, @@ -1504,7 +1504,7 @@ the compiler's comparator and break ties by key Collection only when the application reads it. A pooled live query must publish what its live-query Collection would: the same rows, in its order or else key order, with the same values and status. When its source -starts cleanup, it enters the same terminal error and keeps its last rows. +starts cleanup, it crashes the same way and keeps its last rows. The pooled live query oracle compares the two over generated histories. ### Physical planning and work @@ -1589,9 +1589,8 @@ every row before the top-K (a recorded limit). materialization cells, visible rows, the current private replay state, and required Collection facades—not with settled historical replay attempts or raw delta history. -14. **Cleanup:** cleanup start synchronously closes restart admission, puts - dependent live queries in terminal error, and marks dependent Effects - disposed. The source Collection keeps its prior status until adapter cleanup +14. **Cleanup:** cleanup start synchronously closes restart admission, + crashes dependent live queries, and marks dependent Effects disposed. The source Collection keeps its prior status until adapter cleanup settles; only then does it publish `cleaned-up` and settle its cleanup promise. 15. **Deferred acquisition:** a live-query Collection or pooled live query @@ -1603,41 +1602,50 @@ every row before the top-K (a recorded limit). counts as a preload. A deferring live query keeps its sources from garbage collection but is not counted in their `subscriberCount` or `subscribers:change`, which adapters read as a request for live data. -16. **Final initial errors, crashes, and Collection health:** a sync run's - initial error is final for that sync run; a Collection moves from `error` - to `ready` only through a restart. A source Collection in `error` ends the - sync run of every live query above it, for every shape and at any time. - Before the query's initial-query readiness this is an initial error. - After it, a source reaches `error` only by an initial error during an - on-demand source's own initial sync, or by a crash: IndexedDB closure, a - terminal local persistence failure, or an internal invariant failure. A - query cannot keep serving rows from a Collection that cannot serve reads. - Every other problem after initial-query readiness is health metadata: the - query keeps its status and rows, and its error tree reports the problem. - A lazy load or a root Collection's load that fails after initial-query - readiness is a request error on the query's node. A lazy demand's error - clears when that demand settles or is released; a root Collection's error - clears when a later load of that Collection settles. Query failures and - contract violations, such as a graph failure or a Collection cleaned up - under the query, also end the query. An Effect follows the same rules, - with its initial-query readiness at its settled initial loads: a source in - `error` ends it through `onSourceError`, and a load that fails after its - initial-query readiness is a request error on the Effect's error tree, - delivered through `onHealthChange`, while the Effect keeps running. -17. **Error tree:** a live query's `errors` has one node for itself and one per - source alias in query order, recursing through nested live queries. An - include's source has `kind: 'include'`; when the include materializes a - child Collection per parent row, its node rolls those up as - `instances: { total, inError }`, counting only that include's children - even when another include reads the same alias, and a child's health - change is relayed like a source's. A pooled live query's tree is the tree a - live-query Collection over its source would have, so it also reaches - deeper levels. The shape is fixed by the query. Each node mirrors its - Collection when read, and the query emits `health:change` whenever a node - below it changes. When a Collection's status changes, its `status:change` - fires first and its `health:change` follows at once; the tree is current - when either fires. A node keeps its object identity until something in its - subtree changes, and a change replaces only the nodes on its path. +16. **Status and the error tree:** status says whether the data can be + shown; the error tree says whether it is still kept current. A sync run's + failed first load (`fail()` before Collection readiness) is status + `error` and is final for that sync run. A crash (IndexedDB closure, a + terminal local persistence failure, a throwing sync function, or an + internal invariant failure) is status `crashed` from any active status and + is permanent for the Collection instance. A live query takes its sources' + worst status until its initial-query readiness (`crashed` over `error` + over `loading`). After it, only `crashed` propagates: a query cannot keep + serving rows from a Collection that cannot serve reads. Every other + problem after initial-query readiness, including an on-demand source + whose own first load fails then, leaves the query's status and rows + alone and shows on the node that owns it. A source's `reportError()` is + its node's `failureReason`, which `reportHealthy()` clears; its `fail()` + after Collection readiness is its node's `error`, which stays for the + sync run, and every later report from that sync adapter throws. A lazy + load or a root Collection's load that fails after initial-query readiness + is an `error` on the query's own node. A lazy demand's error clears when + that demand settles or is released; a root Collection's error clears when + a later load of that Collection settles. Query failures and contract + violations, such as a graph failure or a Collection cleaned up under the + query, crash the query. An Effect follows the same rules, with its + initial-query readiness at its settled initial loads: a crashed source, + or a source whose first load fails before that readiness, ends it through + `onSourceError`; after it, every other problem is error tree detail + delivered through `onErrorsChange`, while the Effect keeps running. +17. **Error tree:** a Collection's `errors` is one node; a live query's has + one node for itself and one per source alias in query order, recursing + through nested live queries. A node shows at most one problem: + `failureReason` (the sync adapter still tries) or `error` (the first load + failed, the sync adapter stopped, the Collection crashed, or, on a + query's own node, a request for more data failed). An include's source + has `kind: 'include'`; when the include materializes a child Collection + per parent row, its node rolls those up as `instances: { total, +withErrors }`, counting only that include's children even when another + include reads the same alias, and a child's change is relayed like a + source's. A pooled live query's tree is the tree a live-query Collection + over its source would have, so it also reaches deeper levels. The shape + is fixed by the query. Each node mirrors its Collection when read, and + the query emits `errors:change` whenever a node below it changes. When a + Collection's status changes, its `status:change` fires first and its + `errors:change` follows at once; the tree is current when either fires. + A node keeps its object identity until something in its subtree changes, + and a change replaces only the nodes on its path. ## Glossary diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index 4f293d4f4c..e060adc431 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -7,6 +7,7 @@ import { transformPublicContainers, } from '../compiler/route-metadata.js' import { codedMessage, devBuild } from '../../error-message.js' +import { hasOwnProblem } from '../../collection/error-tree.js' import { BUCKET_FACADE_REF } from './materialized-pipeline.js' import type { Collection } from '../../collection/index.js' import type { SyncConfig } from '../../types.js' @@ -34,7 +35,7 @@ type FacadeEntry = { sync: FacadeSync | undefined order: WeakMap /** Whether the include node counts this facade as in error. */ - readonly inError: boolean + readonly withError: boolean } type SnapshotRow = { @@ -82,7 +83,7 @@ export class BucketFacadeAdapter { private readonly compilations: Array, onMessages: (count: number) => void, // A per-row child Collection's health is part of the parent's error tree. - private readonly onInstanceHealthChange: () => void = () => {}, + private readonly onInstanceErrorsChange: () => void = () => {}, ) { for (const compilation of compilations) { compilation.rows.pipe( @@ -425,7 +426,7 @@ export class BucketFacadeAdapter { const retired = getOrCreate(this.retiredEntries, edgeId, () => new Map()) retired.set(bucketKey, entry) // A retired facade leaves the include node's count. - if (entry.inError) this.onInstanceHealthChange() + if (entry.withError) this.onInstanceErrorsChange() } private getEntry(edgeId: string, bucketKey: string): FacadeEntry { @@ -470,14 +471,14 @@ export class BucketFacadeAdapter { startSync: true, gcTime: 0, }) - // The include node counts the children in error, so only a change in - // that state concerns the parent; startup status changes do not. - let inError = false - collection.on(`health:change`, () => { - const next = collection.status === `error` || collection.health.isError - if (next === inError) return - inError = next - this.onInstanceHealthChange() + // The include node counts the children with a problem, so only a change + // in that state concerns the parent; startup status changes do not. + let withError = false + collection.on(`errors:change`, () => { + const next = hasOwnProblem(collection) + if (next === withError) return + withError = next + this.onInstanceErrorsChange() }) const dispose = collection.cleanup.bind(collection) // Other parent rows can share this facade, so a holder must not end it. @@ -495,8 +496,8 @@ export class BucketFacadeAdapter { get sync() { return sync }, - get inError() { - return inError + get withError() { + return withError }, order, } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 33e8ea8d82..f82d84f042 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -497,7 +497,7 @@ export class CollectionConfigBuilder< const normalized = this.recordSubsetError(error) demand.participant?.reject(normalized) if (this.reportFailedRequest(planId, normalized)) return - this.transitionToError( + this.failQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Subset demand '${planId}' failed: ${normalized.message}` : codedMessage(228, { planId, cause: normalized.message }), @@ -516,7 +516,7 @@ export class CollectionConfigBuilder< this.orderedLoadFailed = true } if (fatalBeforeReady) { - this.transitionToError( + this.failQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Initial subset load failed: ${normalized.message}` : codedMessage(229, { cause: normalized.message }), @@ -700,8 +700,13 @@ export class CollectionConfigBuilder< try { syncState.graph.run() } catch (error) { - if (isCurrentSyncRun()) { - this.transitionToError( + // A first load that already failed in this run, such as a lazy + // load that threw, reported its error. + if ( + isCurrentSyncRun() && + this.liveQueryCollection?.status !== `error` + ) { + this.crashQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Live query graph failed` : codedMessage(212), @@ -999,7 +1004,7 @@ export class CollectionConfigBuilder< (count) => { syncState.messagesCount += count }, - () => this.liveQueryCollection?._emitHealthChange(), + () => this.liveQueryCollection?._emitErrorsChange(), ) : undefined this.bucketFacades = bucketFacades @@ -1184,15 +1189,27 @@ export class CollectionConfigBuilder< ) { const { status } = event - // A source in error ends this query's sync run, for every shape. Before - // initial-query readiness it is an initial error. After it, a source can - // reach error only by crashing, and a query cannot keep serving rows from - // a Collection that cannot serve reads. - if (status === `error`) { - this.transitionToError( + // A crashed source cannot serve reads, so it ends this query at any time. + if (status === `crashed`) { + this.crashQuery( devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' entered error state` + ? `Source collection '${collectionId}' crashed` : codedMessage(172, { collectionId }), + event.collection._lifecycle.getSyncError(), + ) + return + } + + // A source whose first load failed fails this query's first load. Once + // the query has shown data, the failure is a sync problem on the source's + // node: the query keeps its status and rows. + if (status === `error`) { + if (this.liveQueryCollection?.status === `ready`) return + this.failQuery( + devBuild() && process.env.NODE_ENV !== `production` + ? `Source collection '${collectionId}' failed its first load` + : codedMessage(248, { collectionId }), + event.collection._lifecycle.getSyncError(), ) return } @@ -1226,7 +1243,7 @@ export class CollectionConfigBuilder< `Live queries prevent automatic GC, so this was likely a manual cleanup() call.` : codedMessage(152, { collectionId, id: this.id }), ) - this.transitionToError(error.message, error) + this.crashQuery(error.message, error) return error } @@ -1260,28 +1277,34 @@ export class CollectionConfigBuilder< } /** - * Transition the live query to error state + * The query's first load failed: status `error`. A sync problem or a + * request error after the first ready never reaches here. */ - private transitionToError(message: string, error?: unknown) { - this.fatalQueryError = true - this.setErrorState(message, error) + private failQuery(message: string, error?: unknown) { + if (!this.enterErrorState(message)) return + this.liveQueryCollection?._lifecycle.fail(error ?? new Error(message)) } - private setErrorState(message: string, error?: unknown) { - this.isInErrorState = true + /** + * The query can no longer serve reads: a crashed source, a graph failure, + * or a contract violation such as a source cleaned up under this query. + */ + private crashQuery(message: string, error?: unknown) { + this.enterErrorState(message) + this.liveQueryCollection?._lifecycle.crash(error ?? new Error(message)) + } - // Log error to console for debugging + /** Returns false when the query already ended. */ + private enterErrorState(message: string): boolean { + if (this.fatalQueryError) return false + this.fatalQueryError = true + this.isInErrorState = true console.error( devBuild() && process.env.NODE_ENV !== `production` ? `[Live Query Error] ${message}` : message, ) - - // These are query failures, crashed sources, or contract violations such - // as a source cleaned up under this query, so they end the query even - // after initial-query readiness. A source's health error and a request - // error are health metadata and never reach here. - this.liveQueryCollection?._lifecycle.crash(error ?? new Error(message)) + return true } private allRequiredSourcesReady() { @@ -1329,10 +1352,10 @@ export class CollectionConfigBuilder< const statusUnsubscribe = collection.on(`status:change`, (event) => { this.handleSourceStatusChange(config, sourceId, collectionId, event) }) - // Relay health changes so this query's error tree stays current. + // Relay error tree changes so this query's tree stays current. syncState.unsubscribeCallbacks.add( - collection.on(`health:change`, () => - this.liveQueryCollection?._emitHealthChange(), + collection.on(`errors:change`, () => + this.liveQueryCollection?._emitErrorsChange(), ), ) let cleanupStartError: Error | undefined @@ -1353,12 +1376,12 @@ export class CollectionConfigBuilder< // The source may have failed before this live query subscribed. Register // the listener first, then reconcile that current state so no transition // can be missed between observation and subscription. - if (collection.status === `error`) { + if (collection.status === `error` || collection.status === `crashed`) { this.handleSourceStatusChange(config, sourceId, collectionId, { type: `status:change`, collection, - status: `error`, - previousStatus: `error`, + status: collection.status, + previousStatus: collection.status, }) } diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 5285552f4b..6aa65209cd 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -172,9 +172,9 @@ class Partition { const stopStatus = this.source.on(`status:change`, (event) => { this.deliverStatus(event) }) - // Like a live query, a pooled view fails for good when its source - // starts cleanup, before the adapter's cleanup settles; queries - // mounted later get a new partition. + // Like a live query, a pooled view crashes when its source starts + // cleanup, before the adapter's cleanup settles; queries mounted later + // get a new partition. const stopCleanupStart = this.source._onCleanupStart(() => { const previousStatus = this.source.status this.terminate() @@ -182,7 +182,7 @@ class Partition { type: `status:change`, collection: this.source as unknown as Collection, previousStatus, - status: `error`, + status: `crashed`, }) }) this.stopStatusEvents = () => { @@ -194,7 +194,7 @@ class Partition { private deliverStatus(event: CollectionStatusChangeEvent): void { const delivered = this.terminated - ? { ...event, status: `error` as const } + ? { ...event, status: `crashed` as const } : event for (const listener of [...this.statusListeners]) listener(delivered) if (this.terminated) { @@ -510,7 +510,7 @@ class PooledLiveQuery { } get status(): CollectionStatus { - return this.partition.terminated ? `error` : this.source.status + return this.partition.terminated ? `crashed` : this.source.status } private endedProblem: CollectionErrorInfo | undefined @@ -518,10 +518,10 @@ class PooledLiveQuery { /** The same error tree a live-query Collection over this source exposes. */ get errors(): ErrorTreeNode { const source = buildErrorTree(this.source, { alias: this.alias }, this) - // The view shows its source's gate. A terminated partition also ended - // this view, for example when its source was cleaned up under it. + // The view shows its source's status error. A terminated partition also + // ended this view, for example when its source was cleaned up under it. const error = - this.source._lifecycle.gateProblem ?? + this.source._lifecycle.statusProblem ?? (this.partition.terminated ? (this.endedProblem ??= describeProblem( new Error( @@ -529,8 +529,6 @@ class PooledLiveQuery { ? `The pooled live query's source ended` : codedMessage(246), ), - `initial`, - true, )) : null) return errorTreeNode(this, undefined, { @@ -538,6 +536,7 @@ class PooledLiveQuery { collectionId: this.source.id, kind: `live-query`, status: this.status, + failureReason: null, error, sources: [source], }) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 9365ffb659..ba95c1cab4 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -451,20 +451,23 @@ export interface SyncConfig< /** Signal that a usable initial snapshot is available for this attempt. */ markReady: () => void /** - * Signal that the sync adapter stopped trying. Before Collection readiness - * this is the initial error of the sync run: `preload()` rejects with - * `error`, and `markReady()` afterward throws. After it, this is a final - * health error: the status and rows stay, and only a restart clears it. - * While the sync adapter retries, call nothing. + * Report a problem after the first ready while the sync adapter keeps + * trying. It shows as the node's `failureReason`; the status and rows do + * not change. Throws before the first ready and after `fail()`. */ - markError: (error?: unknown) => void + reportError: (error: unknown) => void /** - * Report a problem after Collection readiness while the sync adapter - * keeps trying. The status and rows do not change. Throws before - * Collection readiness. + * The sync adapter stopped. Before the first ready the status becomes + * `error` and `preload()` rejects with `error`. After it, the rows stay and + * the node shows `error` until a reload. Throws after an earlier `fail()`. + * While the sync adapter retries, call nothing or `reportError()`. + */ + fail: (error: unknown) => void + /** + * Sync works again: clears a `failureReason`. With nothing to clear it does + * nothing, so it can follow every commit. Throws before the first ready + * and after `fail()`. */ - reportError: (error: unknown) => void - /** Report that sync works again after `reportError`. */ reportHealthy: () => void /** * Replace the synced rows. By default the replacement also marks the @@ -731,9 +734,9 @@ export type DeleteMutationFn< * @example * // Status transitions * // idle → loading → ready (when markReady() is called) - * // loading → error when the initial sync of an attempt fails for good - * // An initial error is final for its sync attempt. cleanup() and a new - * // preload() or subscriber start a fresh attempt. + * // loading → error when the sync adapter calls fail() before the first ready + * // any active status → crashed when local storage or an invariant breaks + * // After the first ready, sync problems appear in `errors`, not in `status`. */ export type CollectionStatus = /** Collection is created but sync hasn't started yet (when startSync config is false) */ @@ -742,8 +745,13 @@ export type CollectionStatus = | `loading` /** Collection has been explicitly marked ready via markReady() */ | `ready` - /** An error occurred during sync initialization */ + /** The first load of the sync run failed: there is nothing to show. */ | `error` + /** + * Local storage or an internal invariant broke. The Collection cannot serve + * reads or accept writes again; recovery is a new Collection. + */ + | `crashed` /** Collection has been cleaned up and resources freed */ | `cleaned-up` @@ -755,15 +763,6 @@ export type SyncMode = `eager` | `on-demand` * and is omitted from JSON. */ export interface CollectionErrorInfo { - /** - * `initial`: the source gave up before its first ready, so `status` is - * `error`. `sync`: the source reported a problem after its first ready. - * `request`: loading data for a live query failed while its sources stayed - * healthy. - */ - readonly phase: `initial` | `sync` | `request` - /** The source has stopped trying; only a restart clears this problem. */ - readonly final: boolean readonly name: string readonly message: string readonly code?: string @@ -772,16 +771,15 @@ export interface CollectionErrorInfo { readonly cause: unknown } -/** A Collection's health after its first ready. Status still gates the initial load. */ -export interface CollectionHealth { - readonly isError: boolean - readonly error: CollectionErrorInfo | null -} - /** - * One node of a live query's error tree. A live query has one node for itself - * and one for each source alias in query order; the shape is fixed by the - * query, and healthy nodes are present with `isError: false`. + * One node of an error tree. A Collection's tree is one node; a live query's + * has one node for itself and one for each source alias in query order. The + * shape is fixed by the query, and nodes without a problem are present too. + * + * A node shows at most one problem. `failureReason`: something went wrong and + * the sync adapter is still trying, so the problem clears by itself. + * `error`: the first load failed, the sync adapter stopped, the Collection + * crashed, or, on a live query's own node, a request for more data failed. */ export interface ErrorTreeNode { /** The query alias, or `root` for the Collection the tree was read from. */ @@ -789,15 +787,13 @@ export interface ErrorTreeNode { readonly collectionId: string /** `include` is an include's source; its rows feed per-row child Collections. */ readonly kind: `source` | `live-query` | `include` - /** The node's initial-load gate. */ readonly status: CollectionStatus - /** This node has a problem now: a gate error, a health error, or a request error. */ - readonly isError: boolean - /** This node or any node below it has a problem. */ + /** This node or any node below it has a `failureReason` or an `error`. */ readonly hasErrors: boolean + readonly failureReason: CollectionErrorInfo | null readonly error: CollectionErrorInfo | null /** For an include node: its per-row child Collections, and how many have a problem. */ - readonly instances?: { readonly total: number; readonly inError: number } + readonly instances?: { readonly total: number; readonly withErrors: number } readonly sources: ReadonlyArray } diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index c3dd444c3e..b297bad148 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -481,7 +481,7 @@ describe(`Collection cleanup admission oracle`, () => { expect(observeCleanupSettlement(cleanupSettled)).toBe( atCleanupStart.cleanup, ) - expect(observeDependent(live.status !== `error`)).toBe( + expect(observeDependent(live.status !== `crashed`)).toBe( atCleanupStart.dependent, ) expect(await preload).toMatchObject({ @@ -533,7 +533,7 @@ describe(`Collection cleanup admission oracle`, () => { const cleanup = source.cleanup() - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) expect(reports).toHaveBeenCalledTimes(1) cleanupGate.resolve() @@ -894,7 +894,7 @@ describe(`Collection cleanup admission oracle`, () => { await expect(live.preload()).rejects.toThrow(message) - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) expect({ cleaning: cleaningSource.subscriberCount, healthy: healthySource.subscriberCount, @@ -1015,12 +1015,12 @@ describe(`Collection cleanup admission oracle`, () => { publications.length = 0 await source.cleanup() - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) source.startSyncImmediate() expect(source.status).toBe(`ready`) expect(starts).toBe(2) - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) expect(live.get(1)?.rank).toBe(1) expect(publications).toEqual([]) expect(reports).toHaveBeenCalledTimes(1) @@ -1075,23 +1075,22 @@ describe(`Collection cleanup admission oracle`, () => { await live.preload() expect(live.get(1)?.rank).toBe(1) - // After the first ready a source failure is final health: the + // After the first ready a source's fail() stops its sync: the // status and rows stay, and the live query's error tree reports it. - sourceSync.markError(new Error(`source sync failed`)) + sourceSync.fail(new Error(`source sync failed`)) expect(source.status).toBe(`ready`) - expect(source.health.error).toMatchObject({ - phase: `sync`, - final: true, + expect(source.errors.error).toMatchObject({ + message: `source sync failed`, }) expect(live.status).toBe(`ready`) expect(live.get(1)?.rank).toBe(1) - expect(live.errors.sources[0]?.isError).toBe(true) + expect(live.errors.sources[0]?.error).toBeTruthy() - // Only a restart clears a final problem. + // Only a new sync run clears the error. await live.cleanup() await source.cleanup() await live.preload() - expect(source.health.isError).toBe(false) + expect(source.errors.hasErrors).toBe(false) expect(live.errors.hasErrors).toBe(false) expect(starts).toBe(2) diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index 1217ce5b71..ebc89ab509 100644 --- a/packages/db/tests/collection-errors.test.ts +++ b/packages/db/tests/collection-errors.test.ts @@ -248,7 +248,7 @@ describe(`Collection Error Handling`, () => { describe(`Sync Run Isolation`, () => { it(`preserves an asynchronous sync error and removes its first-ready waiter`, async () => { - let markError: (error?: unknown) => void = () => { + let fail: (error?: unknown) => void = () => { throw new Error(`Sync has not started`) } const collection = createCollection<{ id: string }>({ @@ -257,7 +257,7 @@ describe(`Collection Error Handling`, () => { startSync: false, sync: { sync: (sync) => { - markError = sync.markError + fail = sync.fail }, }, }) @@ -268,7 +268,7 @@ describe(`Collection Error Handling`, () => { expect(collection._lifecycle.onFirstReadyCallbacks).toHaveLength(1) const syncError = new Error(`Asynchronous sync failed exactly`) - markError(syncError) + fail(syncError) await expect(preload).rejects.toBe(syncError) await expect(stateWhenReady).rejects.toBe(syncError) await expect(arrayWhenReady).rejects.toBe(syncError) @@ -279,7 +279,7 @@ describe(`Collection Error Handling`, () => { }) it(`uses the generic state error when asynchronous sync supplies no cause`, async () => { - let markError: (error?: unknown) => void = () => { + let fail: (error?: unknown) => void = () => { throw new Error(`Sync has not started`) } const collection = createCollection<{ id: string }>({ @@ -288,13 +288,13 @@ describe(`Collection Error Handling`, () => { startSync: false, sync: { sync: (sync) => { - markError = sync.markError + fail = sync.fail }, }, }) const preload = collection.preload() - markError() + fail() await expect(preload).rejects.toBeInstanceOf( CollectionIsInErrorStateError, @@ -303,7 +303,7 @@ describe(`Collection Error Handling`, () => { }) it(`ignores an error callback retained after cleanup`, async () => { - let markError: () => void = () => { + let fail: (error: unknown) => void = () => { throw new Error(`Sync has not started`) } const collection = createCollection<{ id: string }>({ @@ -312,7 +312,7 @@ describe(`Collection Error Handling`, () => { startSync: false, sync: { sync: (sync) => { - markError = sync.markError + fail = sync.fail }, }, }) @@ -322,14 +322,14 @@ describe(`Collection Error Handling`, () => { }) await collection.cleanup() await cancelled - markError() + fail(new Error(`late`)) expect(collection.status).toBe(`cleaned-up`) }) it(`ignores an error callback retained by an earlier sync run`, async () => { const syncRuns: Array<{ - markError: () => void + fail: (error: unknown) => void markReady: () => void }> = [] const collection = createCollection<{ id: string }>({ @@ -337,8 +337,8 @@ describe(`Collection Error Handling`, () => { getKey: (item) => item.id, startSync: false, sync: { - sync: ({ markError, markReady }) => { - syncRuns.push({ markError, markReady }) + sync: ({ fail, markReady }) => { + syncRuns.push({ fail, markReady }) }, }, }) @@ -356,7 +356,7 @@ describe(`Collection Error Handling`, () => { expect(syncRuns).toHaveLength(2) const second = syncRuns[1]! - first.markError() + first.fail(new Error(`stale`)) expect(collection.status).toBe(`loading`) second.markReady() @@ -423,7 +423,7 @@ describe(`Collection Error Handling`, () => { }) await expect(collection.preload()).rejects.toBe(startupError) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) }) it(`should throw helpful errors when trying to use operations on error status collection`, async () => { @@ -443,7 +443,7 @@ describe(`Collection Error Handling`, () => { } catch { // Expected to throw } - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) // Now operations should be blocked with helpful messages expect(() => { @@ -612,7 +612,7 @@ describe(`Collection Error Handling`, () => { ), ).not.toThrow() expect(() => - collectionImpl._lifecycle.validateStatusTransition(`ready`, `error`), + collectionImpl._lifecycle.validateStatusTransition(`ready`, `crashed`), ).not.toThrow() // Valid transitions from error. An initial error is final for its sync diff --git a/packages/db/tests/collection-lifecycle.test.ts b/packages/db/tests/collection-lifecycle.test.ts index 46f983b9ff..efc8d7b6a1 100644 --- a/packages/db/tests/collection-lifecycle.test.ts +++ b/packages/db/tests/collection-lifecycle.test.ts @@ -117,10 +117,10 @@ describe(`Collection Lifecycle Management`, () => { const collection = createCollection<{ id: string }>({ getKey: ({ id }) => id, sync: { - sync: ({ collection: source, markReady, markError }) => { + sync: ({ collection: source, markReady, fail }) => { starts++ if (starts > 1 || phase === `ready`) markReady() - else if (phase === `failed`) markError(failure) + else if (phase === `failed`) fail(failure) else if (phase === `starting`) void source.cleanup() }, }, @@ -765,7 +765,7 @@ describe(`Collection Lifecycle Management`, () => { } if (from === `error`) { collection._lifecycle.setStatus(`loading`) - collection._lifecycle.markError(syncFailure) + collection._lifecycle.fail(syncFailure) expect(collection._lifecycle.getSyncError()).toBe(syncFailure) } else if (from === `cleaned-up`) { collection._lifecycle.setStatus(`cleaned-up`) @@ -888,7 +888,7 @@ describe(`Collection Lifecycle Management`, () => { const firstReady = vi.fn() collection.onFirstReady(firstReady) // An internal failure during the ready transition still ends it. A - // source's markError at this point would be health metadata instead. + // source's fail at this point would be health metadata instead. collection.on(`status:ready`, () => { collection._lifecycle.crash(failure) }) @@ -896,7 +896,7 @@ describe(`Collection Lifecycle Management`, () => { collection._lifecycle.markReady() - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(failure) expect(collection._lifecycle.hasBeenReady).toBe(false) expect(firstReady).not.toHaveBeenCalled() @@ -1171,7 +1171,7 @@ describe(`Collection Lifecycle Management`, () => { try { await expect(collection.preload()).rejects.toBe(adapterFailure) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) } finally { await collection.cleanup() } @@ -1196,7 +1196,7 @@ describe(`Collection Lifecycle Management`, () => { try { expect(() => collection._sync.startSync()).toThrow(adapterFailure) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) let didThrow = false let thrown: unknown @@ -1207,10 +1207,11 @@ describe(`Collection Lifecycle Management`, () => { thrown = error } - // The failed entry ended this sync attempt; it cannot become ready. - expect(didThrow).toBe(true) - expect(thrown).toBeInstanceOf(InvalidCollectionStatusTransitionError) - expect(collection.status).toBe(`error`) + // The failed entry crashed the Collection. Core ended the sync run, + // so the adapter's later markReady() does nothing and is no error. + expect(didThrow).toBe(false) + expect(thrown).toBeUndefined() + expect(collection.status).toBe(`crashed`) } finally { await collection.cleanup() } diff --git a/packages/db/tests/collection-mutation-startup-oracle.test.ts b/packages/db/tests/collection-mutation-startup-oracle.test.ts index fe3931e774..63dacaf38c 100644 --- a/packages/db/tests/collection-mutation-startup-oracle.test.ts +++ b/packages/db/tests/collection-mutation-startup-oracle.test.ts @@ -574,7 +574,7 @@ describe(`Collection mutation startup oracle`, () => { } expect(captureError(mutate)).toBe(startupError) expect(syncStarts).toBe(1) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect( collection.toArray.map(({ id, value }) => ({ id, value })), ).toEqual(initialData) diff --git a/packages/db/tests/collection-query-publication-boundaries.test.ts b/packages/db/tests/collection-query-publication-boundaries.test.ts index f5197fc4e1..1397085616 100644 --- a/packages/db/tests/collection-query-publication-boundaries.test.ts +++ b/packages/db/tests/collection-query-publication-boundaries.test.ts @@ -184,11 +184,11 @@ it.each([`depth`, `cycle`] as const)( : `Cannot hash cyclic structural values`, ) expect(source.has(1)).toBe(true) - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) sync.begin() sync.write({ type: `insert`, value: { id: 2, nested: {} } }) sync.commit() - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) expect(live.toArray).toEqual(before) } finally { await live.cleanup() diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index 940ffc58e9..e1bfe342b6 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -2182,7 +2182,7 @@ describe(`Collection.subscribeChanges`, () => { expect(() => collection.subscribeChanges(() => {})).toThrow(failure) expect(collection.subscriberCount).toBe(0) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) }) it(`preserves setup failure when subscription cleanup also throws`, () => { diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index b719660130..50008e1159 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -54,7 +54,7 @@ const acquisitionEntries = [ `request`, `resume`, `markReady`, - `markError`, + `fail`, `syncReturn`, ] as const type AcquisitionPhase = (typeof acquisitionPhases)[number] @@ -71,7 +71,7 @@ const acquisitionCellDefinitions = { kind: `excluded`, reason: `the sync callback has not started and cannot mark ready`, }, - 'deferred:markError': { + 'deferred:fail': { kind: `excluded`, reason: `the sync callback has not started and cannot mark error`, }, @@ -85,7 +85,7 @@ const acquisitionCellDefinitions = { reason: `resuming the deferred gate enters this phase only once`, }, 'starting:markReady': { kind: `covered` }, - 'starting:markError': { kind: `covered` }, + 'starting:fail': { kind: `covered` }, 'starting:syncReturn': { kind: `covered` }, 'on-demand:request': { kind: `covered` }, 'on-demand:resume': { @@ -93,7 +93,7 @@ const acquisitionCellDefinitions = { reason: `an installed loader is no longer behind the deferred gate`, }, 'on-demand:markReady': { kind: `covered` }, - 'on-demand:markError': { kind: `covered` }, + 'on-demand:fail': { kind: `covered` }, 'on-demand:syncReturn': { kind: `excluded`, reason: `the sync callback already returned the installed loader`, @@ -107,7 +107,7 @@ const acquisitionCellDefinitions = { kind: `excluded`, reason: `eager readiness does not install a subset loader`, }, - 'eager:markError': { + 'eager:fail': { kind: `excluded`, reason: `eager errors do not change subset acquisition availability`, }, @@ -124,7 +124,7 @@ const acquisitionCellDefinitions = { kind: `excluded`, reason: `callbacks from a retiring sync run cannot restore availability`, }, - 'retiring:markError': { + 'retiring:fail': { kind: `excluded`, reason: `callbacks from a retiring sync run are obsolete`, }, @@ -139,9 +139,9 @@ const acquisitionCellDefinitions = { }, 'unavailable:markReady': { kind: `excluded`, - reason: `markReady after markError in one sync attempt is a contract violation and throws`, + reason: `markReady after fail in one sync attempt is a contract violation and throws`, }, - 'unavailable:markError': { + 'unavailable:fail': { kind: `excluded`, reason: `a repeated error leaves acquisition unavailable`, }, @@ -2046,7 +2046,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, }, ) - const removeErrorListener = collection.on(`status:error`, () => { + const removeErrorListener = collection.on(`status:crashed`, () => { if (!requestOnError) return requestOnError = false subscription.requestSnapshot({ @@ -2061,7 +2061,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { requestOnError = true expect(() => collection.startSyncImmediate()).toThrow(syncFailure) expect(observed).toEqual([expect.any(Promise)]) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(loads).toEqual([{ syncRunGeneration: 0, demand: `old` }]) await collection.cleanup() @@ -2757,7 +2757,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { reach(`starting:syncReturn`) expect(observed).toEqual([expect.any(Promise)]) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(loads.map(({ where }) => where)).toEqual([oldWhere]) await flushPromises() expect(outcomes).toHaveLength(1) @@ -2968,7 +2968,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { ) acquisitionCase( - [`starting:markError`], + [`starting:fail`], `keeps demand requested during initial error unacquired until a restart`, async (reach) => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) @@ -2980,7 +2980,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { id: `sync-entry-error-ready-recovery`, startSync: false, sync: { - sync: ({ markError, markReady }) => { + sync: ({ fail, markReady }) => { if (syncRunCount++ !== 1) { markReady() return { @@ -2992,8 +2992,8 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { } } recover = markReady - markError(new Error(`initial sync failed`)) - reach(`starting:markError`) + fail(new Error(`initial sync failed`)) + reach(`starting:fail`) return { loadSubset: (options) => { loads.push(options) @@ -3236,14 +3236,14 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { it(`keeps an installed loader unavailable after its initial error`, async () => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const loads: Array = [] - let markError!: (error: unknown) => void + let fail!: (error: unknown) => void let markReady!: () => void const collection = createOnDemandCollection<{ id: string }>({ id: `installed-loader-error-ready-recovery`, startSync: false, sync: { sync: (operations) => { - markError = operations.markError + fail = operations.fail markReady = operations.markReady return { loadSubset: (options) => { @@ -3260,7 +3260,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, }) - markError(new Error(`initial sync failed`)) + fail(new Error(`initial sync failed`)) expect(() => markReady()).toThrow(InvalidCollectionStatusTransitionError) subscription.requestSnapshot({ where }) await flushPromises() @@ -3276,14 +3276,14 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const loads: Array = [] const unloads: Array = [] - let markError!: (error: unknown) => void + let fail!: (error: unknown) => void let markReady!: () => void const collection = createOnDemandCollection<{ id: string }>({ id: `release-unavailable-demand`, startSync: false, sync: { sync: (operations) => { - markError = operations.markError + fail = operations.fail markReady = operations.markReady return { loadSubset: (options) => { @@ -3300,7 +3300,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, }) - markError(new Error(`initial sync failed`)) + fail(new Error(`initial sync failed`)) subscription.requestSnapshot({ where }) subscription.releaseSnapshot(where) // A restart is the only recovery; the released demand stays released. @@ -3317,20 +3317,20 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }) acquisitionCase( - [`on-demand:markError`], + [`on-demand:fail`], `keeps demand from an installed loader's initial error unacquired until a restart`, async (reach) => { const oldWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`old`)]) const newWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`new`)]) const loads: Array = [] - let markError!: (error: unknown) => void + let fail!: (error: unknown) => void let markReady!: () => void const collection = createOnDemandCollection<{ id: string }>({ id: `installed-loader-initial-error`, startSync: false, sync: { sync: (operations) => { - markError = operations.markError + fail = operations.fail markReady = operations.markReady return { loadSubset: (options) => { @@ -3349,10 +3349,10 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { subscription.requestSnapshot({ where: oldWhere }) const removeErrorListener = collection.on(`status:error`, () => { subscription.requestSnapshot({ where: newWhere }) - reach(`on-demand:markError`) + reach(`on-demand:fail`) }) - markError(new Error(`initial sync failed`)) + fail(new Error(`initial sync failed`)) await flushPromises() expect.soft(collection.status).toBe(`error`) @@ -3492,7 +3492,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(() => collection.startSyncImmediate()).toThrow(syncFailure) await flushPromises() - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(subscription.status).toBe(`ready`) subscription.unsubscribe() diff --git a/packages/db/tests/conformance-disabled-laws.test.ts b/packages/db/tests/conformance-disabled-laws.test.ts index 3eef8d1bab..d1920ce6c8 100644 --- a/packages/db/tests/conformance-disabled-laws.test.ts +++ b/packages/db/tests/conformance-disabled-laws.test.ts @@ -11,7 +11,9 @@ function disabled( state: representation === `absent` ? undefined : new Map(), status: `disabled`, isReady: true, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, errors: undefined, } @@ -46,7 +48,7 @@ const faults: Array<[string, (value: ConformanceResult) => void]> = [ [ `error`, (value) => { - value.isError = true + value.isLoadingError = true }, ], [ @@ -57,8 +59,8 @@ const faults: Array<[string, (value: ConformanceResult) => void]> = [ collectionId: `stale`, kind: `live-query`, status: `ready`, - isError: false, hasErrors: false, + failureReason: null, error: null, sources: [], } diff --git a/packages/db/tests/conformance-result-laws.test.ts b/packages/db/tests/conformance-result-laws.test.ts index 0f6564a62c..1c23d5c967 100644 --- a/packages/db/tests/conformance-result-laws.test.ts +++ b/packages/db/tests/conformance-result-laws.test.ts @@ -16,12 +16,17 @@ it(`checks declared result types without copying or repairing the surface`, () = const raw = { status: `ready`, isReady: false, - isError: false, + isLoadingError: false, isEnabled: false, data: [{ value: undefined }], } expect(expectResultSurface(raw)).toBe(raw) - for (const key of [`status`, `isReady`, `isError`, `isEnabled`] as const) { + for (const key of [ + `status`, + `isReady`, + `isLoadingError`, + `isEnabled`, + ] as const) { const malformed = { ...raw, [key]: undefined } expect(() => expectResultSurface(malformed)).toThrowError( new RegExp(`raw ${key}`), diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index ebada7dbe8..35ddab75b0 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -80,15 +80,15 @@ export interface LaggingSourceHandle< /** * A source Collection that reaches Collection readiness at once with its - * initial rows, then lets the scenario play its sync adapter's health reports. + * initial rows, then lets the scenario play its sync adapter's reports. */ -export interface HealthSourceHandle< +export interface SyncSourceHandle< T extends { id: string } = Row, > extends SourceHandle { - /** The sync adapter reports a health error and keeps trying. */ + /** The sync adapter reports a problem and keeps trying: a `failureReason`. */ reportError: (error: unknown) => void - /** The sync adapter gives up: a final health error after readiness. */ - markError: (error: unknown) => void + /** The sync adapter stops after readiness: the node's `error`. */ + fail: (error: unknown) => void /** The sync adapter reports that sync works again. */ reportHealthy: () => void } @@ -126,7 +126,11 @@ export interface ConformanceResult { persistedStatus?: string isPersistedReady?: boolean persistedError?: unknown - isError: boolean + isLoadingError: boolean + /** Local storage or an invariant broke: `status` is `crashed`. */ + isCrashed: boolean + /** The data is shown and the error tree has a problem. */ + isSyncError: boolean isEnabled: boolean /** * The hook's error tree, read without normalization so scenarios can judge @@ -184,10 +188,10 @@ export interface LiveQueryDriver { ) => SourceHandle /** Create a source that starts `loading` and readies on demand (keyed by `id`). */ makeDeferredSource: () => DeferredSourceHandle - /** Create a ready source whose sync adapter health the scenario controls. */ - makeHealthSource: ( + /** Create a ready source whose sync adapter reports the scenario controls. */ + makeSyncSource: ( initialData: ReadonlyArray, - ) => HealthSourceHandle + ) => SyncSourceHandle /** Create an on-demand source that lags behind its settled subset (keyed by `id`). */ makeLaggingSource: () => LaggingSourceHandle /** @@ -198,8 +202,12 @@ export interface LiveQueryDriver { build: QueryBuild, opts?: { startSync?: boolean }, ) => { collection: Collection } - /** Create a source whose sync fails, driving it into `error` status. */ - makeErrorSource: () => { + /** + * Create a source that fails during startup: `fail` calls `fail()` before + * the first ready (`status: 'error'`), `crash` throws from its sync function + * (`status: 'crashed'`). + */ + makeErrorSource: (kind: `fail` | `crash`) => { collection: Collection expectedError: Error startup: { returned: true } | { returned: false; error: unknown } @@ -224,7 +232,7 @@ export interface LiveQueryDriver { knownGaps?: ReadonlyArray /** * How the adapter surfaces a query error (see the `error-status` scenario): - * - `flag` (default): a readable `isError`/`status === 'error'` on the result. + * - `flag` (default): a readable `isLoadingError`/`status === 'error'` on the result. * - `throw`: reading the errored result throws, for a framework error boundary * to catch (e.g. Solid's `createResource`/`` model). */ diff --git a/packages/db/tests/conformance/disabled-laws.ts b/packages/db/tests/conformance/disabled-laws.ts index 0924f8b199..ff8c386ed1 100644 --- a/packages/db/tests/conformance/disabled-laws.ts +++ b/packages/db/tests/conformance/disabled-laws.ts @@ -9,7 +9,7 @@ export function expectDisabledResult( expect(result.status).toBe(`disabled`) expect(result.isEnabled).toBe(false) expect(result.isReady).toBe(true) - expect(result.isError).toBe(false) + expect(result.isLoadingError).toBe(false) // A disabled query has no query definition, so it has no aliases to report. expect(result.errors).toBeUndefined() if (representation === `absent`) { diff --git a/packages/db/tests/conformance/error-source.ts b/packages/db/tests/conformance/error-source.ts new file mode 100644 index 0000000000..e7f19651c5 --- /dev/null +++ b/packages/db/tests/conformance/error-source.ts @@ -0,0 +1,42 @@ +import type { Collection } from '@tanstack/db' + +/** + * Builds a source Collection that fails during startup, from the adapter's + * own `createCollection`, so every driver faces the same failure. + * + * - `fail`: the sync adapter calls `fail()` before its first ready, a failed + * first load (`status: 'error'`). Startup returns normally. + * - `crash`: the sync function throws, a programming error that crashes the + * Collection (`status: 'crashed'`). Startup rethrows the error. + */ +export function makeErrorSource( + createCollection: (options: any) => Collection, + id: string, + kind: `fail` | `crash`, +): { + collection: Collection + expectedError: Error + startup: { returned: true } | { returned: false; error: unknown } +} { + const expectedError = new Error(`conformance: sync ${kind}`) + let startup: { returned: true } | { returned: false; error: unknown } = { + returned: true, + } + const collection = createCollection({ + id, + getKey: (row: { id: string }) => row.id, + startSync: false, + sync: { + sync: (params: { fail: (error: unknown) => void }) => { + if (kind === `crash`) throw expectedError + params.fail(expectedError) + }, + }, + }) + try { + collection.startSyncImmediate() + } catch (error) { + startup = { returned: false, error } + } + return { collection, expectedError, startup } +} diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index 0f415b6f18..46349a7003 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -45,7 +45,7 @@ export interface InfiniteQueryResult { pageParams: Array hasNextPage: boolean isFetchingNextPage: boolean - isError: boolean + isLoadingError: boolean status: string collection: Collection } diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index ae41e16058..8a2df7fcd7 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -891,7 +891,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { await handle.flush() expect(handle.current().pages.map((page) => page.length)).toEqual([3]) expect(handle.current().status).toBe(`ready`) - expect(handle.current().isError).toBe(false) + expect(handle.current().isLoadingError).toBe(false) utils.setWindow = originalSetWindow await handle.fetchNextPage() diff --git a/packages/db/tests/conformance/result-laws.ts b/packages/db/tests/conformance/result-laws.ts index 437364263d..ad63103da8 100644 --- a/packages/db/tests/conformance/result-laws.ts +++ b/packages/db/tests/conformance/result-laws.ts @@ -5,12 +5,12 @@ export function expectResultSurface< T extends { status: unknown isReady: unknown - isError: unknown + isLoadingError: unknown isEnabled: unknown }, >(result: T): T { expect(typeof result.status, `raw status`).toBe(`string`) - for (const key of [`isReady`, `isError`, `isEnabled`] as const) + for (const key of [`isReady`, `isLoadingError`, `isEnabled`] as const) expect(typeof result[key], `raw ${key}`).toBe(`boolean`) return result } diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index b4d302e08c..d592bbcb71 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -24,7 +24,7 @@ * groupBy/aggregate, nested aggregates, `.includes` subqueries, findOne * cardinality, disabled + transitions, deferred readiness / eager / ready-with- * no-data, initial readiness over a lagging on-demand source, Collection - * health after readiness, param recompilation, optimistic mutation, pre-created & config-object + * sync errors after readiness, param recompilation, optimistic mutation, pre-created & config-object * inputs, error status, and order-only moves. */ import { describe, expect, it } from 'vitest' @@ -86,11 +86,11 @@ export function runSuite(rawDriver: LiveQueryDriver) { sources!.track(rawDriver.makeDeferredSource()), makeLaggingSource: () => sources!.track(rawDriver.makeLaggingSource()), - makeHealthSource: (data: ReadonlyArray) => - sources!.track(rawDriver.makeHealthSource(data)), + makeSyncSource: (data: ReadonlyArray) => + sources!.track(rawDriver.makeSyncSource(data)), makePrecreated: (build, options) => sources!.track(rawDriver.makePrecreated(build, options)), - makeErrorSource: () => sources!.track(rawDriver.makeErrorSource()), + makeErrorSource: (kind) => sources!.track(rawDriver.makeErrorSource(kind)), mount: (build) => track(rawDriver.mount(build)), mountControllable: (build, initial) => track(rawDriver.mountControllable(build, initial)), @@ -1038,73 +1038,85 @@ export function runSuite(rawDriver: LiveQueryDriver) { }, ) - scenario( - `error-status`, - `a failing source surfaces an error (flag or boundary)`, - async () => { - const source = driver.makeErrorSource() - // Source ownership is registered before judging a failed setup. - expect(source.startup.returned, `source startup threw`).toBe(false) - if (!source.startup.returned) - expect(source.startup.error, `intended source startup error`).toBe( - source.expectedError, + // A failed first load and a crash both stop a fresh mount from showing + // data, and they need different handling: a failed first load is + // `status: 'error'` (`isLoadingError`), recovered by a new sync run; a + // crash is `status: 'crashed'` (`isCrashed`), permanent for the Collection + // instance. Both explain themselves on the mounted Collection's node with + // the exact error the source produced. + for (const kind of [`fail`, `crash`] as const) { + const status = kind === `fail` ? `error` : `crashed` + scenario( + kind === `fail` ? `error-status` : `crashed-status`, + kind === `fail` + ? `a source whose first load fails surfaces a loading error (flag or boundary)` + : `a crashed source surfaces a crash (flag or boundary)`, + async () => { + const source = driver.makeErrorSource(kind) + // Source ownership is registered before judging a failed setup. + // Only a crash makes startup throw. + expect(source.startup.returned, `source startup returned`).toBe( + kind === `fail`, ) - const h = driver.mountCollection(source.collection) - await h.flush() + if (!source.startup.returned) + expect(source.startup.error, `intended source startup error`).toBe( + source.expectedError, + ) + const h = driver.mountCollection(source.collection) + await h.flush() - if (driver.errorSurface === `throw`) { - // Boundary model: reading the errored result throws (for an error - // boundary to catch), rather than exposing a readable flag. - expect(() => h.current()).toThrow() - } else { - const result = h.current() - expect(result.status).toBe(`error`) - expect(result.isError).toBe(true) - // An initial error also explains itself in the error tree: the - // mounted Collection's own node carries it with phase `initial`. - expect(result.errors?.hasErrors).toBe(true) - expect(result.errors?.isError).toBe(true) - expect(result.errors?.error).toMatchObject({ - phase: `initial`, - final: true, - message: source.expectedError.message, - }) - // The binding exposes the exact value the sync adapter gave up with. - expect(result.errors?.error?.cause).toBe(source.expectedError) - } - h.unmount() - }, - ) + if (driver.errorSurface === `throw`) { + // Boundary model: reading the failed result throws (for an error + // boundary to catch), rather than exposing a readable flag. + expect(() => h.current()).toThrow() + } else { + const result = h.current() + expect(result.status).toBe(status) + expect(result.isLoadingError).toBe(kind === `fail`) + expect(result.isCrashed).toBe(kind === `crash`) + expect(result.isSyncError).toBe(false) + expect(result.errors?.hasErrors).toBe(true) + expect(result.errors?.failureReason).toBeNull() + expect(result.errors?.error).toMatchObject({ + message: source.expectedError.message, + }) + // The binding exposes the exact value the source failed with. + expect(result.errors?.error?.cause).toBe(source.expectedError) + } + h.unmount() + }, + ) + } - // Collection health after Collection readiness. The law (ARCHITECTURE.md - // law 16 and decisions 5, 6, and 10 of the source-failure PLAN): Collection - // status is only the initial gate, so health reports after readiness never - // change status, `isError`, or rows. They appear only in the error tree, on - // the node for the source alias. `reportHealthy()` clears a health error - // that is not final; a final health error stays until a restart. The tree - // keeps its object identity until its content changes. + // ---- sync problems after readiness ------------------------------- + // After readiness a sync problem never changes status, rows, or the + // load flags. It appears only in the error tree, on the source alias's + // node, and sets `isSyncError`. `reportError()` sets the node's + // `failureReason` and `reportHealthy()` clears it; `fail()` sets its + // `error`, which only a new sync run clears, and after it every report + // from the sync adapter throws. The tree keeps its object identity until + // its content changes (ARCHITECTURE.md laws 16 and 17, + // docs/contributing/designs/source-failure-and-health/error-surface). // // The model below is the whole expectation: one optional problem with a - // serial number. A report is a new problem with a new serial, so the tree - // must be a new object; a call that changes nothing keeps the serial, so - // the tree must be the same object. Row writes do not touch the model. - // Each step is observed after the driver's `flush` cut. + // field and a serial number. A report is a new problem with a new serial, + // so the tree must be a new object; a call that changes nothing keeps the + // serial, so the tree must be the same object. Row writes do not touch + // the model. Each step is observed after the driver's `flush` cut. scenario( - `health-after-readiness`, - `health reports after readiness appear only in the error tree`, + `sync-errors-after-readiness`, + `sync problems after readiness appear only in the error tree`, async () => { - type Problem = { final: boolean; cause: Error; serial: number } + type Problem = { + field: `failureReason` | `error` + cause: Error + serial: number + } let problem: Problem | null = null let serial = 0 - const report = (cause: Error, final: boolean) => { - if (problem?.final) return - problem = { final, cause, serial: ++serial } - } - const healthy = () => { - if (problem && !problem.final) problem = null - } + const stopped = () => problem?.field === `error` - const source = driver.makeHealthSource(SEED) + const source = driver.makeSyncSource(SEED) const h = driver.mount((q) => q .from({ items: source.collection }) @@ -1118,46 +1130,49 @@ export function runSuite(rawDriver: LiveQueryDriver) { const result = h.current() const tree = result.errors expect(result.status, step).toBe(`ready`) - expect(result.isError, `${step}: isError is the initial gate`).toBe( - false, + expect( + result.isLoadingError, + `${step}: isLoadingError is the first load`, + ).toBe(false) + expect(result.isSyncError, `${step}: isSyncError`).toBe( + problem !== null, ) expectUnorderedRows(result.data, rows) expect(tree, step).toBeDefined() const node = tree!.sources.find(({ alias }) => alias === `items`) + const shown = (info: { message: string } | null | undefined) => + info ? { message: info.message } : null expect( { - rootIsError: tree!.isError, + rootError: tree!.error, hasErrors: tree!.hasErrors, node: node && { kind: node.kind, status: node.status, - isError: node.isError, - error: node.error && { - phase: node.error.phase, - final: node.error.final, - message: node.error.message, - }, + failureReason: shown(node.failureReason), + error: shown(node.error), }, }, step, ).toEqual({ - rootIsError: false, + rootError: null, hasErrors: problem !== null, node: { kind: `source`, status: `ready`, - isError: problem !== null, - error: problem && { - phase: `sync`, - final: problem.final, - message: problem.cause.message, - }, + failureReason: + problem?.field === `failureReason` + ? { message: problem.cause.message } + : null, + error: + problem?.field === `error` + ? { message: problem.cause.message } + : null, }, }) // The binding exposes the exact value the sync adapter reported. - expect(node?.error?.cause, `${step}: error cause`).toBe( - problem?.cause, - ) + const info = node?.failureReason ?? node?.error + expect(info?.cause, `${step}: error cause`).toBe(problem?.cause) const current = { tree, serial: problem?.serial ?? null } if (previous) { if (previous.serial === current.serial) @@ -1181,48 +1196,49 @@ export function runSuite(rawDriver: LiveQueryDriver) { await h.flush() observe(`a row write`) + // Each step either applies to the model or, after fail(), throws. const steps: Array<[string, () => void]> = [ [ - `a health error that is not final`, + `a failure reason`, () => { const stalled = new Error(`stream stalled`) source.reportError(stalled) - report(stalled, false) + problem = { + field: `failureReason`, + cause: stalled, + serial: ++serial, + } }, ], [ `reportHealthy clears it`, () => { source.reportHealthy() - healthy() + problem = null }, ], [ - `a final health error`, - () => { - const ended = new Error(`stream ended`) - source.markError(ended) - report(ended, true) - }, + `reportHealthy with nothing to clear changes nothing`, + () => source.reportHealthy(), ], [ - `reportHealthy cannot clear a final health error`, + `fail sets the error`, () => { - source.reportHealthy() - healthy() + const ended = new Error(`stream ended`) + source.fail(ended) + problem = { field: `error`, cause: ended, serial: ++serial } }, ], + [`reportHealthy after fail throws`, () => source.reportHealthy()], [ - `a later report cannot replace a final health error`, - () => { - const late = new Error(`late`) - source.reportError(late) - report(late, false) - }, + `reportError after fail throws`, + () => source.reportError(new Error(`late`)), ], ] for (const [step, act] of steps) { - act() + const throws = stopped() + if (throws) expect(act, `${step}: throws`).toThrow() + else act() await h.flush() observe(step) } @@ -1232,7 +1248,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { row.id === `1` ? { id: `1`, name: `Johnny Doe` } : row, ) await h.flush() - observe(`rows still publish under a final health error`) + observe(`rows still publish after fail`) h.unmount() }, ) @@ -1274,7 +1290,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(33) + expect(registry.size).toBe(34) }) }) } diff --git a/packages/db/tests/conformance/health-source.ts b/packages/db/tests/conformance/sync-source.ts similarity index 82% rename from packages/db/tests/conformance/health-source.ts rename to packages/db/tests/conformance/sync-source.ts index a77511cbfd..d5263efa74 100644 --- a/packages/db/tests/conformance/health-source.ts +++ b/packages/db/tests/conformance/sync-source.ts @@ -1,24 +1,24 @@ import type { Collection } from '@tanstack/db' -import type { HealthSourceHandle } from './contract' +import type { SyncSourceHandle } from './contract' /** - * Builds the `HealthSourceHandle` fixture from the adapter's own + * Builds the `SyncSourceHandle` fixture from the adapter's own * `createCollection`, so the Collection lives in the adapter's realm. Every * driver uses this one fixture, so the five bindings face the same sync * adapter reports. */ -export function makeHealthSource( +export function makeSyncSource( createCollection: (options: any) => Collection, id: string, initialData: ReadonlyArray, -): HealthSourceHandle { +): SyncSourceHandle { let controls: | { begin: () => void write: (message: { type: string; value: T }) => void commit: () => void markReady: () => void - markError: (error: unknown) => void + fail: (error: unknown) => void reportError: (error: unknown) => void reportHealthy: () => void } @@ -48,7 +48,7 @@ export function makeHealthSource( update: (row) => writeRows(`update`, [row]), remove: (row) => writeRows(`delete`, [row]), reportError: (error) => controls!.reportError(error), - markError: (error) => controls!.markError(error), + fail: (error) => controls!.fail(error), reportHealthy: () => controls!.reportHealthy(), } } diff --git a/packages/db/tests/db-client-preload-identity-oracle.test.ts b/packages/db/tests/db-client-preload-identity-oracle.test.ts index baa25a4eae..355ef41eef 100644 --- a/packages/db/tests/db-client-preload-identity-oracle.test.ts +++ b/packages/db/tests/db-client-preload-identity-oracle.test.ts @@ -223,10 +223,8 @@ describe(`DbClient preload source identity`, () => { getKey: (row: Row) => row.id, startSync: true, sync: { - sync: ({ - markError, - }: Parameters[`sync`]>[0]) => { - failFirst = () => markError(new Error(`first source failed`)) + sync: ({ fail }: Parameters[`sync`]>[0]) => { + failFirst = () => fail(new Error(`first source failed`)) }, }, }) @@ -261,7 +259,8 @@ describe(`DbClient preload source identity`, () => { }).liveQueries?.[0] expect(stream?.queryHash).toBe(queryHash) failFirst() - await expect(pending).rejects.toThrow(/entered error state/) + // The live query reports its source's own failure. + await expect(pending).rejects.toThrow(/first source failed/) expect(client.dehydrate().liveQueries).toBeUndefined() client.hydrate({ diff --git a/packages/db/tests/effect.test.ts b/packages/db/tests/effect.test.ts index a462d185fc..c769789a47 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -1983,7 +1983,7 @@ describe(`createEffect`, () => { })), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), + onErrorsChange: (errors) => trees.push(errors), }) try { @@ -2002,7 +2002,6 @@ describe(`createEffect`, () => { expect(sourceErrors).toEqual([]) expect(effect.disposed).toBe(false) expect(trees.at(-1)?.error).toMatchObject({ - phase: `request`, message: failure.message, }) } finally { @@ -2062,7 +2061,7 @@ describe(`createEffect`, () => { })), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), + onErrorsChange: (errors) => trees.push(errors), }) try { @@ -2080,7 +2079,6 @@ describe(`createEffect`, () => { expect(sourceErrors).toEqual([]) expect(effect.disposed).toBe(false) expect(trees.at(-1)?.error).toMatchObject({ - phase: `request`, message: failure.message, }) expect(unloadCount).toBe(1) diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index de2ae56d93..1b37ce7b2b 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -310,8 +310,8 @@ }, "135": { "file": "query/effect.ts", - "template": "Source collection '${collectionId}' entered error state", - "literals": ["Source collection '", "' entered error state"] + "template": "Source collection '${collectionId}' entered ${status} state", + "literals": ["Source collection '", "' entered ", " state"] }, "136": { "file": "query/expression-helpers.ts", @@ -535,8 +535,8 @@ }, "172": { "file": "query/live/collection-config-builder.ts", - "template": "Source collection '${collectionId}' entered error state", - "literals": ["Source collection '", "' entered error state"] + "template": "Source collection '${collectionId}' crashed", + "literals": ["Source collection '", "' crashed"] }, "173": { "file": "collection/lifecycle.ts", @@ -931,16 +931,29 @@ }, "245": { "file": "collection/lifecycle.ts", - "template": "${operation}() reports a problem after Collection readiness, but Collection \"${this.id}\" is ${this.status}. Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up.", + "template": "${operation}() reports on sync after the first ready, but Collection \"${this.id}\" is ${this.status}. Before the first ready, retry without reporting, or call fail() when the sync adapter stops.", "literals": [ - "() reports a problem after Collection readiness, but Collection \"", + "() reports on sync after the first ready, but Collection \"", "\" is ", - ". Before Collection readiness, retry without reporting, or call markError() when the sync adapter gives up." + ". Before the first ready, retry without reporting, or call fail() when the sync adapter stops." ] }, "246": { "file": "query/pooled-live-query.ts", "template": "The pooled live query's source ended", "literals": ["The pooled live query's source ended"] + }, + "247": { + "file": "collection/lifecycle.ts", + "template": "${operation}() was called after fail() in the same sync run of Collection \"${this.id}\". fail() means the sync adapter stopped; a reload starts a new sync run.", + "literals": [ + "() was called after fail() in the same sync run of Collection \"", + "\". fail() means the sync adapter stopped; a reload starts a new sync run." + ] + }, + "248": { + "file": "query/live/collection-config-builder.ts", + "template": "Source collection '${collectionId}' failed its first load", + "literals": ["Source collection '", "' failed its first load"] } } diff --git a/packages/db/tests/index-update-oracle.property.test.ts b/packages/db/tests/index-update-oracle.property.test.ts index bafc931160..d4dfb8fdbb 100644 --- a/packages/db/tests/index-update-oracle.property.test.ts +++ b/packages/db/tests/index-update-oracle.property.test.ts @@ -1640,7 +1640,7 @@ describe.each([ expect(() => write(collection, sync, { id: 2, name: `bob` })).toThrow( TypeError, ) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(() => collection.insert({ id: 3, name: `cy` })).toThrow( CollectionInErrorStateError, ) diff --git a/packages/db/tests/indexed-db/ORACLE.md b/packages/db/tests/indexed-db/ORACLE.md index a8ba9fa2a2..684c4a263d 100644 --- a/packages/db/tests/indexed-db/ORACLE.md +++ b/packages/db/tests/indexed-db/ORACLE.md @@ -150,7 +150,7 @@ arbitrary browser lock scheduling. ## Managed connection ownership `createIndexedDB` closes native admission before notifying Collections on -`versionchange` or descriptor `close()`. Each active Collection enters `error`; +`versionchange` or descriptor `close()`. Each active Collection enters `crashed`; late startup on that descriptor also rejects. Core sync cleanup is a different operation: already accepted sync work and admitted writes retain their obligations. Raw `descriptor.db.close()` bypasses managed notification and is outside this diff --git a/packages/db/tests/indexed-db/compatibility-oracle.test.ts b/packages/db/tests/indexed-db/compatibility-oracle.test.ts index 42695a15b6..eb115ace17 100644 --- a/packages/db/tests/indexed-db/compatibility-oracle.test.ts +++ b/packages/db/tests/indexed-db/compatibility-oracle.test.ts @@ -609,7 +609,7 @@ for (const count of [2, 3]) { 'managed initial handles do not orphan an upgrade blocker', ).toBe(0) for (const collection of collections) - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') }) }) } diff --git a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts index 7eaa4644dd..5a856d39ec 100644 --- a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts @@ -4,8 +4,8 @@ * The omission history uses delete/reinsert via public APIs, so a partial-row * mutant cannot hide behind draft undefined values or import truncation. * The status history observes caught receiver failure independently of promise - * fulfillment. After the first ready, that failure is final health, which the - * recorder adds to the status history. Cleanup histories keep old native reads alive after retirement. + * fulfillment. After the first ready, that failure is the Collection's + * `error` with its rows kept, which the recorder adds to the status history. Cleanup histories keep old native reads alive after retirement. * Native scheduling itself is received separately by the browser suite. */ import { expect, it } from 'vitest' @@ -56,7 +56,7 @@ async function omission(fault: 'none' | 'partial' | 'error') { commit: (...args) => { const result = params.commit(...args) if (++commits > 1 && fault === 'error') - params.markError(new Error('spurious receiver error control')) + params.fail(new Error('spurious receiver error control')) return result }, }), @@ -206,7 +206,7 @@ for (const abort of [false, true]) { }) } -it('reports an active receiver abort through Collection health while its callback fulfills', async () => { +it('reports an active receiver abort as the Collection error while its callback fulfills', async () => { await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -225,10 +225,11 @@ it('reports an active receiver abort through Collection health while its callbac await gate.release() await receipt.completion expect(receipt.status).toBe('fulfilled') - // After the first ready a receiver failure is final health, not status. - expect(record.statuses).toContain('health-error') + // After the first ready a receiver failure stops sync but keeps status. + expect(record.statuses).toContain('sync-error') expect(receiver.status).toBe('ready') - expect(receiver.health.error).toMatchObject({ phase: 'sync', final: true }) + expect(receiver.errors.error).toBeTruthy() + expect(receiver.errors.failureReason).toBeNull() assertSnapshot( receiver.values(), [], diff --git a/packages/db/tests/indexed-db/deletion-queue-oracle.test.ts b/packages/db/tests/indexed-db/deletion-queue-oracle.test.ts index 36fd225baf..ae67931094 100644 --- a/packages/db/tests/indexed-db/deletion-queue-oracle.test.ts +++ b/packages/db/tests/indexed-db/deletion-queue-oracle.test.ts @@ -101,7 +101,7 @@ it.each(cells)( await second }) await blocked[0]!.promise - expect.soft(old.status).toBe('error') + expect.soft(old.status).toBe('crashed') assertRows(old.values(), a, 'announced deletion retains A') expect(settled).toEqual([false, false]) expect(nativeSucceeded).toEqual([false, false]) @@ -156,7 +156,7 @@ it.each(cells)( expect(nativeSucceeded[1], 'B blocker prevents native success').toBe( false, ) - expect.soft(fresh.status).toBe('error') + expect.soft(fresh.status).toBe('crashed') assertRows(fresh.values(), b, 'B retained while newer deletion blocked') assertRows( (await readStore({ ...freshDb, db: blockerB }, 'items')).rows, @@ -175,7 +175,7 @@ it.each(cells)( b, 'B retained after native completion before caller', ) - expect.soft(fresh.status).toBe('error') + expect.soft(fresh.status).toBe('crashed') receipts[1]!.resolve() await second receipts[0]!.resolve() @@ -184,9 +184,9 @@ it.each(cells)( expect(settled).toEqual([true, true]) assertRows(old.values(), a, 'A receipt never empties A') assertRows(fresh.values(), b, 'B receipt never empties B') - expect.soft([old.status, fresh.status]).toEqual(['error', 'error']) - expect.soft(oldStatuses).toEqual(['error']) - expect.soft(freshStatuses).toEqual(['error']) + expect.soft([old.status, fresh.status]).toEqual(['crashed', 'crashed']) + expect.soft(oldStatuses).toEqual(['crashed']) + expect.soft(freshStatuses).toEqual(['crashed']) for (const rows of oldRows) assertRows(rows, a, 'every A publication') for (const rows of freshRows) assertRows(rows, b, 'every B publication') // A fresh open proves native deletion did occur. Retained Collection rows @@ -256,7 +256,7 @@ it('a delete queued during A can delete the intervening recreated B', async () = [], 'B was natively deleted', ) - expect.soft(old.status).toBe('error') + expect.soft(old.status).toBe('crashed') }) }) @@ -274,7 +274,7 @@ it('retains errored snapshots when all obligations finish before announcement', await gate.release() blocker.close() await deleteDatabase(h.db.name) - expect(c.status).toBe('error') + expect(c.status).toBe('crashed') assertRows(c.values(), rows, 'unblocked deletion retains snapshot') const fresh = await h.connect() assertRows( diff --git a/packages/db/tests/indexed-db/recorder.ts b/packages/db/tests/indexed-db/recorder.ts index 4d0b543a71..a122b7a5c3 100644 --- a/packages/db/tests/indexed-db/recorder.ts +++ b/packages/db/tests/indexed-db/recorder.ts @@ -15,7 +15,7 @@ export type RecordedEvent = { export function recordCollection( collection: Pick< Collection, - 'subscribeChanges' | 'on' | 'values' | 'status' | 'health' + 'subscribeChanges' | 'on' | 'values' | 'status' | 'errors' >, id: string, sink: (event: RecordedEvent) => void = () => {}, @@ -40,10 +40,11 @@ export function recordCollection( statuses.push(status) append({ type: 'status', status }) }) - // After the first ready, a source failure is health, not status. Record it - // in the status history so a status law still observes it. - const stopHealth = collection.on('health:change', () => { - if (collection.health.isError) statuses.push('health-error') + // After the first ready, a stopped sync is the Collection's `error`, not a + // status. Record it in the status history so a status law still observes it. + const stopErrors = collection.on('errors:change', () => { + if (collection.status === 'ready' && collection.errors.error) + statuses.push('sync-error') }) const subscription = collection.subscribeChanges( (changes) => { @@ -74,7 +75,7 @@ export function recordCollection( events, stop: () => { stopStatus() - stopHealth() + stopErrors() subscription.unsubscribe() }, } diff --git a/packages/db/tests/indexed-db/retirement-oracle.test.ts b/packages/db/tests/indexed-db/retirement-oracle.test.ts index 38deefef7f..795eac8a2f 100644 --- a/packages/db/tests/indexed-db/retirement-oracle.test.ts +++ b/packages/db/tests/indexed-db/retirement-oracle.test.ts @@ -96,13 +96,13 @@ function recordStatus(c: Subject) { return statuses } function closed(c: Subject, statuses: Array) { - expect.soft(c.status, 'closed connection status').toBe('error') - const firstError = statuses.indexOf('error') - expect.soft(firstError, 'retirement is observable').toBeGreaterThanOrEqual(0) + expect.soft(c.status, 'closed connection status').toBe('crashed') + const firstCrash = statuses.indexOf('crashed') + expect.soft(firstCrash, 'retirement is observable').toBeGreaterThanOrEqual(0) expect .soft( - statuses.slice(firstError).every((status) => status === 'error'), - 'retired status stays error', + statuses.slice(firstCrash).every((status) => status === 'crashed'), + 'retired status stays crashed', ) .toBe(true) } @@ -650,7 +650,7 @@ for (const reason of reasons) h.disposers.push(() => native.restore()) const attempts: Array> = [] c.on('status:change', ({ status }) => { - if (status !== 'error') return + if (status !== 'crashed') return attempts.push( observe(sibling.utils.importData([{ id: 1, name: 'forbidden' }])), ) @@ -668,7 +668,7 @@ for (const reason of reasons) 'rejected', ]) expect(native.entries).toHaveLength(0) - expect([c.status, sibling.status]).toEqual(['error', 'error']) + expect([c.status, sibling.status]).toEqual(['crashed', 'crashed']) assertRows( (await readStore(inspector, 'other')).rows, [], @@ -757,7 +757,7 @@ it('notifies sibling Collections while preserving listener error reporting', asy sibling = await h.open('other') const failure = new Error('application status listener') first.on('status:change', ({ status }) => { - if (status === 'error') throw failure + if (status === 'crashed') throw failure }) const errors: Array = [] const scheduled = vi @@ -767,7 +767,7 @@ it('notifies sibling Collections while preserving listener error reporting', asy }) try { expect(() => h.db.close()).not.toThrow() - expect([first.status, sibling.status]).toEqual(['error', 'error']) + expect([first.status, sibling.status]).toEqual(['crashed', 'crashed']) expect(errors).toHaveLength(1) expect(errors[0]).toThrow(failure) await expect(sibling.utils.clearObjectStore()).rejects.toThrow() diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index 9bd0cb849e..7d93b71e6e 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -335,9 +335,9 @@ it('retires every managed store without publishing database deletion', async () } } expect([source.status, sameStore.status, sibling.status]).toEqual([ - 'error', - 'error', - 'error', + 'crashed', + 'crashed', + 'crashed', ]) assertRows( source.values(), @@ -424,7 +424,7 @@ it('retains every public snapshot and sends no deletion before native deletion s collection === sibling ? [other] : [item], 'deletion retains errored snapshot', ) - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') } const fresh = await createIndexedDB({ name: h.db.name, @@ -636,7 +636,7 @@ for (const count of [2, 3]) { ) } for (const [index, collection] of collections.entries()) { - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') expect(() => collection.insert({ id: 'closed-' + index, name: 'rejected' }), ).toThrow() @@ -837,7 +837,7 @@ it('ignores an old native deletion receipt after database recreation', async () }) const deletion = deleteDatabase(h.db.name) await nativeComplete.promise - expect(old.status).toBe('error') + expect(old.status).toBe('crashed') const recreated = await createIndexedDB({ name: h.db.name, version: 1, @@ -880,17 +880,17 @@ it('reports connection closure immediately and supports fresh Collections', asyn h.descriptors.push(upgraded) // Closure crashes the source, and a crashed source ends every live query // above it, even one that was already ready. Both trees explain why. - expect(old.status).toBe('error') - expect(live.status).toBe('error') + expect(old.status).toBe('crashed') + expect(live.status).toBe('crashed') expect(live.errors.sources[0]).toMatchObject({ - status: 'error', - isError: true, + status: 'crashed', + error: expect.anything(), }) const fresh = await h.open('items', { db: upgraded }) await fresh.insert({ id: 1, name: 'new' }).isPersisted.promise await Channel.deliver() - expect(old.status).toBe('error') - expect(live.status).toBe('error') + expect(old.status).toBe('crashed') + expect(live.status).toBe('crashed') expect(live.errors.hasErrors).toBe(true) expect(fresh.status).toBe('ready') assertRows( @@ -1002,7 +1002,7 @@ it('preserves a recreated peer while a newer deletion is blocked', async () => { }) const firstDeletion = deleteDatabase(h.db.name) await nativeComplete.promise - expect(old.status).toBe('error') + expect(old.status).toBe('crashed') const freshDb = await h.connect() const peerDb = await h.connect() const fresh = await h.open('items', { db: freshDb }) diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index 6ba058ed65..80194dc0af 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -696,7 +696,7 @@ describe(`preload answered by a DbClient stream`, () => { * The same options can be retried, but the failed source Collection remains * in error until explicitly restarted. That retry must report the source error * rather than wait on the failed query's former pending stream. A controlled - * later `markError` crosses the asynchronous failure cut: the pending preload + * later `fail` crosses the asynchronous failure cut: the pending preload * rejects, its stream disappears, and a healthy replacement of the same * query hash can publish its own row while the peer source remains ready. A * failed stream has no reusable result, so a replacement observer may attach @@ -720,9 +720,9 @@ describe(`DbClient preload releases independent source starts`, () => { getKey: (row: { id: string }) => row.id, startSync: true, sync: { - sync: ({ markError }) => { + sync: ({ fail }) => { firstStarts++ - failFirst = () => markError(failure) + failFirst = () => fail(failure) }, }, })) @@ -780,7 +780,8 @@ describe(`DbClient preload releases independent source starts`, () => { ).toHaveLength(1) failFirst() - await expect(pending).rejects.toThrow(/entered error state/) + // The live query reports its source's own failure. + await expect(pending).rejects.toThrow(/late source startup failure/) expect( client.dehydrate({ shouldDehydrateLiveQuery: () => true }).liveQueries, ).toBeUndefined() @@ -862,7 +863,7 @@ describe(`DbClient preload releases independent source starts`, () => { client.dehydrate({ shouldDehydrateLiveQuery: () => true }).liveQueries, ).toBeUndefined() await expect(client.preloadLiveQuery(options)).rejects.toThrow( - /entered error state/, + /source start failed/, ) expect(starts).toBe(1) expect( @@ -1187,7 +1188,7 @@ describe(`a source whose start throws on resumption`, () => { ) // The failed start puts the source and its live query in error, so the // supported recovery is cleanup and a new sync run, not a retry in place. - expect(live.status).toBe(`error`) + expect(live.status).toBe(`crashed`) await live.cleanup() await flaky.cleanup() // A new preload after cleanup starts a new sync run and must resume. diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 53f8261853..f4517d1818 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -1103,9 +1103,9 @@ describe(`createLiveQueryObserver`, () => { expect(observer.getSnapshot().data).toHaveLength(2) expect(entriesSpy).toHaveBeenCalledTimes(1) - source._lifecycle.setStatus(`error`) + source._lifecycle.setStatus(`crashed`) - expect(observer.getSnapshot().status).toBe(`error`) + expect(observer.getSnapshot().status).toBe(`crashed`) expect(observer.getSnapshot().state?.size).toBe(2) // Status-only changes reuse the immutable row capture. expect(entriesSpy).toHaveBeenCalledTimes(1) @@ -1142,10 +1142,10 @@ describe(`createLiveQueryObserver`, () => { // Status transitions carry no row changes; the observer must publish // them through the same canonical path as data changes. - source._lifecycle.setStatus(`error`) + source._lifecycle.setStatus(`crashed`) source._lifecycle.setStatus(`cleaned-up`) - expect(statuses).toContain(`error`) + expect(statuses).toContain(`crashed`) expect(statuses).toContain(`cleaned-up`) observer.dispose() }) diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 0c77aafc11..97dcb45135 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -511,10 +511,10 @@ describe(`createLiveQueryWindowController`, () => { }) await expect(controller.preload()).rejects.toBe(failure) - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) await controller.preload() - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) controller.dispose() }) @@ -602,7 +602,7 @@ describe(`createLiveQueryWindowController`, () => { expect(await expansionOutcome).toBe(failure) expect(await preloadOutcome).toBe(failure) expect(controller.getSnapshot().pages).toHaveLength(1) - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) await controller.fetchNextPage() } else { resolveExpansion() @@ -710,9 +710,10 @@ describe(`createLiveQueryWindowController`, () => { await expect(controller.fetchNextPage()).rejects.toBe(failure) expect(controller.getSnapshot().pages).toHaveLength(1) // The failed load is a request error; the window keeps its status. - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) + expect(controller.getSnapshot().isSyncError).toBe(true) expect(controller.getSnapshot().errors?.error).toMatchObject({ - phase: `request`, + message: failure.message, }) expect(lq.utils.lastSubsetError).toBe(failure) expect(lq.utils.customUtility()).toBe(`custom`) @@ -859,7 +860,7 @@ describe(`createLiveQueryWindowController`, () => { if (outcome === `reject`) { expect(await resetOutcome).toBe(failure) expect(await expansionOutcome).toBe(failure) - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) expect(ids(controller.getSnapshot())).toEqual([`1`, `2`]) await controller.reset() } else { @@ -867,7 +868,7 @@ describe(`createLiveQueryWindowController`, () => { expect(await expansionOutcome).toBeUndefined() } expect(controller.getSnapshot().pages).toHaveLength(1) - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) await controller.fetchNextPage() expect(ids(controller.getSnapshot())).toEqual([`1`, `2`, `3`, `4`]) @@ -1267,7 +1268,7 @@ describe(`createLiveQueryWindowController`, () => { rejectFirst(new Error(`stale attachment failed`)) await flush() - expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().isLoadingError).toBe(false) controller.dispose() }) @@ -1448,7 +1449,7 @@ describe(`createLiveQueryWindowController`, () => { isLoading: false, isReady: true, isIdle: false, - isError: false, + isLoadingError: false, isCleanedUp: false, }) controller.dispose() diff --git a/packages/db/tests/local-storage-peer-oracle.test.ts b/packages/db/tests/local-storage-peer-oracle.test.ts index d7563854ea..48aa49e6b3 100644 --- a/packages/db/tests/local-storage-peer-oracle.test.ts +++ b/packages/db/tests/local-storage-peer-oracle.test.ts @@ -1602,7 +1602,7 @@ for (const [name, raw] of [ await withHistoryCleanup( async () => { await expect(collection.preload()).rejects.toThrow() - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') expect(host.storage.getItem('shared')).toBe(raw) collection.utils.clearStorage() reopened = makeCollection(host, 'shared', 'reopened') @@ -1805,7 +1805,7 @@ it('does not retain a failed startup validation run', async () => { await expect(collection.preload()).rejects.toThrow( 'bad row cannot be serialized', ) - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') expect(host.storage.getItem('shared')).toBe(bad) expect(begins, 'failed restore opens no transaction').toBe(0) host.storage.setItem('shared', good) @@ -1855,7 +1855,7 @@ it('rejects null stored row data before opening a sync transaction', async () => await withHistoryCleanup( async () => { await expect(collection.preload()).rejects.toThrow() - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') expect(host.storage.getItem('shared')).toBe(raw) expect(begins, 'malformed restore opens no transaction').toBe(0) }, @@ -1911,7 +1911,7 @@ for (const failureKind of ['storage', 'parser'] as const) { await withHistoryCleanup( async () => { await expect(collection.preload()).rejects.toThrow('transient') - expect(collection.status).toBe('error') + expect(collection.status).toBe('crashed') expect(host.storage.getItem('shared')).toBe(stored) await collection.cleanup() collection.startSyncImmediate() diff --git a/packages/db/tests/local-storage.test.ts b/packages/db/tests/local-storage.test.ts index 7a446b7dbb..b824eac66b 100644 --- a/packages/db/tests/local-storage.test.ts +++ b/packages/db/tests/local-storage.test.ts @@ -346,7 +346,7 @@ describe(`localStorage collection`, () => { try { await expect(collection.preload()).rejects.toThrow(SyntaxError) expect(reads).toHaveBeenCalledWith(`todos`) - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(mockStorage.getItem(`todos`)).toBe(`invalid json data`) expect([...collection.values()]).toEqual([]) collection.utils.clearStorage() diff --git a/packages/db/tests/preload-gc.test.ts b/packages/db/tests/preload-gc.test.ts index 453bda5fa9..dec89e9ff0 100644 --- a/packages/db/tests/preload-gc.test.ts +++ b/packages/db/tests/preload-gc.test.ts @@ -12,21 +12,21 @@ describe(`preload retention`, () => { function makeCollection(startSync = false, gcTime = 10) { let ready!: () => void - let fail!: (error: Error) => void + let failSync!: (error: Error) => void const cleanup = vi.fn() const collection = createCollection<{ id: number }>({ getKey: (row) => row.id, gcTime, startSync, sync: { - sync: ({ begin, write, commit, markReady, markError }) => { + sync: ({ begin, write, commit, markReady, fail }) => { ready = () => { begin() write({ type: `insert`, value: { id: 1 } }) commit() markReady() } - fail = markError + failSync = fail return cleanup }, }, @@ -35,7 +35,7 @@ describe(`preload retention`, () => { return { collection, ready: () => ready(), - fail: (error: Error) => fail(error), + fail: (error: Error) => failSync(error), cleanup, } } diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index b5f6097b8e..1a911ee895 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -1726,7 +1726,7 @@ async function expectFailedConsolidationKeepsVisibleRows(): Promise { // After the first ready, the failed replacement is a request error on the // live query's node. The query stays ready and keeps established rows. expect(live.status).toBe(`ready`) - expect(live.errors.error).toMatchObject({ phase: `request` }) + expect(live.errors.error).toEqual(expect.anything()) expect(live.utils.lastSubsetError).toBe(failure) expect(visibleCommentIds()).toEqual([200, 300]) } catch (error) { diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 20dc67ffe3..85111e08f2 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1000,10 +1000,10 @@ describe(`createLiveQueryCollection`, () => { expect(activeUsers.size).toBe(2) // Tear down the source collection and the live query, e.g. when switching - // to a different data set at runtime. Cleaning up a source collection puts - // the dependent live query into an error state. + // to a different data set at runtime. Cleaning up a source collection + // crashes the dependent live query. await usersCollection.cleanup() - expect(activeUsers.status).toBe(`error`) + expect(activeUsers.status).toBe(`crashed`) await activeUsers.cleanup() expect(activeUsers.status).toBe(`cleaned-up`) @@ -2043,7 +2043,7 @@ describe(`createLiveQueryCollection`, () => { // After the first ready, the failed request is a request error on the // live query's node; the query stays ready. expect(live.status).toBe(`ready`) - expect(live.errors.error).toMatchObject({ phase: `request` }) + expect(live.errors.error).toEqual(expect.anything()) expect(live.utils.lastSubsetError).toBe(failure) } finally { await Promise.all([live.cleanup(), users.cleanup(), issues.cleanup()]) diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index 0e7ce1dcd9..c2605fb6b8 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -196,7 +196,7 @@ describe(`loadSubset replay refinement`, () => { publications.length = 0 await source.cleanup() - if (live) expect(live.status).toBe(`error`) + if (live) expect(live.status).toBe(`crashed`) source.startSyncImmediate() await flushPromises() expect(loads).toBe(2) @@ -224,7 +224,7 @@ describe(`loadSubset replay refinement`, () => { expect(read()).toEqual(expected) expect(readEvents()).toEqual(expected) expect(publications).toEqual(publishes ? [replacement] : []) - if (live) expect(live.status).toBe(`error`) + if (live) expect(live.status).toBe(`crashed`) } catch (error) { primaryFailure = { error } throw error diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index 583c5ef24f..e319b31ffb 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -236,8 +236,8 @@ describe(`pooled live query gcTime`, () => { await vi.advanceTimersByTimeAsync(1) const cleanup = source.cleanup() await vi.advanceTimersByTimeAsync(1) - expect(compiledView.observer.getSnapshot().status).toBe(`error`) - expect(pooledView.observer.getSnapshot().status).toBe(`error`) + expect(compiledView.observer.getSnapshot().status).toBe(`crashed`) + expect(pooledView.observer.getSnapshot().status).toBe(`crashed`) release() await cleanup pooledView.stop() diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index 79eaa3de2a..c411365213 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -133,7 +133,7 @@ function startEffect( const callbacks = { onBatch: () => {}, onSourceError: (error: Error) => sourceErrors.push(error), - onHealthChange: (errors: ErrorTreeNode) => trees.push(errors), + onErrorsChange: (errors: ErrorTreeNode) => trees.push(errors), } if (path === `ordered`) { return createEffect({ @@ -346,9 +346,8 @@ describe(`loadSubset failure matrix`, () => { // its tree, as on a live query's node; the Effect keeps running. expect(sourceErrors).toEqual([]) expect(effect.disposed).toBe(false) + expect(trees.at(-1)?.failureReason).toBeNull() expect(trees.at(-1)?.error).toMatchObject({ - phase: `request`, - final: false, ...(failureValue === `error` ? { message: (error as Error).message } : {}), @@ -370,8 +369,9 @@ describe(`loadSubset failure matrix`, () => { expect(live.status).toBe(`ready`) if (path === `lazy`) { expect(live.errors).toMatchObject({ - isError: true, - error: { phase: `request`, final: false }, + hasErrors: true, + failureReason: null, + error: expect.anything(), }) } if (failureValue === `error`) { @@ -451,7 +451,7 @@ describe(`loadSubset failure matrix`, () => { ), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), + onErrorsChange: (errors) => trees.push(errors), }) : undefined const live = @@ -482,7 +482,6 @@ describe(`loadSubset failure matrix`, () => { expect(sourceErrors).toEqual([]) expect(effect.disposed).toBe(false) expect(trees.at(-1)?.error).toMatchObject({ - phase: `request`, message: String(failure), }) } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index c46bf42c9d..ce4ff2f10f 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1,58 +1,56 @@ /** - * # Source failure and health + * # Source failure and the error tree * * A Collection answers two questions on two separate axes: * - * - **The initial gate (`status`)** says whether the data can be shown yet. It is - * decided only by the initial sync of a sync run. While the sync adapter retries - * it stays `loading`. When the sync adapter gives up before Collection readiness - * (`markError`), the sync run fails for good: the status is `error`, and a - * later `markReady()` in the same sync run is a contract violation that - * throws. Only a restart (`cleanup()`, then a new preload or subscriber) - * begins a new sync run. - * - **Health** says whether the data is still kept up to date. It is metadata - * after Collection readiness: `reportError(error)` records a problem while - * the sync adapter keeps trying, `reportHealthy()` clears it, and - * `markError()` after Collection readiness records a final problem, because - * the sync adapter gave up. The status and the rows do not change. Only the - * sync adapter clears its problem, and nothing clears a final problem - * except a restart. + * - **Status** says whether the data can be shown. It is decided by the first + * load of a sync run. While the sync adapter retries it stays `loading`. + * When the sync adapter stops before Collection readiness (`fail`), the + * status is `error`: the first load failed, and a later `markReady()` in the + * same sync run is a contract violation that throws. When local storage or + * an invariant breaks, the status is `crashed`, from any active status and + * for good. Only a new sync run leaves `error`. + * - **The error tree** says whether the data on screen is still kept current. + * After Collection readiness, `reportError(error)` sets the Collection's + * `failureReason` while the sync adapter keeps trying, `reportHealthy()` + * clears it, and `fail()` sets its `error`: the sync adapter stopped. The + * status and the rows do not change. A node shows at most one problem, and + * nothing clears an `error` within a sync run. Once the adapter called + * `fail()`, every later report from it is a contract violation that throws. + * After a crash, adapter calls do nothing: core ended the run. * * A live query exposes both axes for everything it depends on through * `collection.errors`: one node for itself and one per source alias in query * order, recursing through nested live queries. The tree always has the same * shape for a given query. Each node mirrors its Collection at the moment it - * is read, and the live query emits `health:change` when a node below it + * is read, and the live query emits `errors:change` when a node below it * changes, so a reader knows to read the tree again. When a status changes, - * `status:change` fires first and `health:change` follows at once, and the + * `status:change` fires first and `errors:change` follows at once, and the * tree is current when either fires. A reader can compare trees with `===`: * a node keeps its identity until something in its subtree changes, and a * change replaces only the nodes on its path. * * An include's source node has `kind: 'include'` and rolls up the include's - * per-row child Collections as `instances: { total, inError }`. A pooled - * live query shows its source's gate and the same tree a live-query + * per-row child Collections as `instances: { total, withErrors }`. A pooled + * live query shows its source's status and the same tree a live-query * Collection over that source would have, so its tree also reaches through * a nested live query. * - * A crash is not health either. When a Collection can no longer serve reads - * or admit writes, as after IndexedDB closure or a terminal local persistence - * failure, it moves to `error` even after Collection readiness. The IndexedDB - * and persistence oracles own those causes; this oracle drives the transition - * directly. + * The IndexedDB and persistence oracles own the causes of a crash; this + * oracle drives the transition directly. * - * A live query's own gate follows its sources: it becomes ready when every - * Collection is ready, and a Collection that fails before the query's initial-query readiness - * fails the query's sync run, whatever the query's shape. After the query's - * initial-query readiness, its sources report problems only as health, and - * the query keeps its status and rows. The one exception is a crash: a query - * cannot keep serving rows from a Collection that cannot serve reads, so a - * crashed Collection ends every live query above it, as cleanup does. + * A live query's own status follows its sources until it is ready: it becomes + * ready when every Collection is ready, fails its first load when one fails + * its first load, and crashes when one crashes. After the query has shown + * data, only a crash changes its status: a query cannot keep serving rows + * from a Collection that cannot serve reads. Every other source problem stays + * on the source's node, and the query keeps its status and rows. * * ## The model * - * The model keeps, for each Collection, its initial gate and its health problem (none, - * non-final, or final), with a serial for each reported problem. It derives + * The model keeps, for each Collection, its status and its problem after + * readiness (none, a failure reason, or an error), with a serial for each + * reported problem. It derives * each live query's gate from its Collections, using only the rules above, and * the include's parent rows from whether `B` synced its row while the * sync run was running. It builds the expected tree from those facts. It does @@ -61,13 +59,13 @@ * ## The history grammar * * Steps act on Collection `A` or `B`: `ready` writes the Collection's row and calls - * `markReady()`; `fail` calls `markError()`; `report` calls `reportError()`; - * `healthy` calls `reportHealthy()`; `crash` moves the - * Collection to `error` through its lifecycle, as a crash cause would. `restart` cleans up the + * `markReady()`; `fail` calls `fail()`; `report` calls `reportError()`; + * `healthy` calls `reportHealthy()`; `crash` crashes the Collection through + * its lifecycle, as a crash cause would. `restart` cleans up the * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw - * for `markReady()` after `markError()`, and for a health report before - * Collection readiness. Six shapes cross the grammar: a direct Collection, a join, a + * for `markReady()` after `fail()`, for any report after `fail()`, and for a + * report before Collection readiness. Six shapes cross the grammar: a direct Collection, a join, a * self-join of one Collection under two aliases, a nested live query, an * include child that materializes a Collection per parent row, and a pooled * live query over a nested live query. @@ -79,9 +77,9 @@ * error class. It also checks identity: reading twice gives the same tree, * and between steps a node is the same object exactly when its expected * facts, including problem serials, did not change. At every event it checks - * that no `health:change` shows a status that `status:change` has not + * that no `errors:change` shows a status that `status:change` has not * announced, and that each `status:change` is followed at once by - * `health:change`. When the expected tree changed, the last event must show + * `errors:change`. When the expected tree changed, the last event must show * the current tree. Request lanes check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's * node, and that it clears. Effect lanes check the Effect's gate and its @@ -90,7 +88,7 @@ * ## Limits * * Collections are eager, so a live query cannot become ready while a Collection is - * still loading. The on-demand case, where a Collection can fail its initial sync + * still loading. The on-demand case, where a Collection fails its first load * after the live query is ready, is not generated here. Per-row child * Collections are rolled up, not nodes, and no grammar step fails one; the * include-instances lane fails one and checks the roll-up, the relay, and @@ -137,15 +135,21 @@ type Step = // Independent model // --------------------------------------------------------------------------- -type Gate = `loading` | `ready` | `error` +type Gate = `loading` | `ready` | `error` | `crashed` // `serial` names one reported problem. A new report is a new problem even -// when it has the same finality, so the node that shows it is a new node. -type Problem = null | { final: boolean; serial: number } -type SourceModel = { gate: Gate; problem: Problem } +// when it has the same field, so the node that shows it is a new node. +type Problem = null | { + field: `failureReason` | `error` + message: string + serial: number +} +// `cause` is the message of what put the gate in `error` or `crashed`. +type GateModel = { gate: Gate; cause: string | null } +type SourceModel = GateModel & { problem: Problem } type Model = { sources: Record - query: Gate - inner: Gate + query: GateModel + inner: GateModel serial: number // Parent rows the include query's sync run has received from `B`. parentRows: number @@ -161,34 +165,47 @@ const shapeSources: Record> = { } function freshModel(): Model { - const source = (): SourceModel => ({ gate: `loading`, problem: null }) + const source = (): SourceModel => ({ + gate: `loading`, + cause: null, + problem: null, + }) return { sources: { A: source(), B: source() }, - query: `loading`, - inner: `loading`, + query: { gate: `loading`, cause: null }, + inner: { gate: `loading`, cause: null }, serial: 0, parentRows: 0, } } -// A live query's gate follows its sources and never leaves error within a -// sync run. An input in `error` ends the query's sync run whenever it appears. -// After the query is ready, an input reaches `error` only by crashing, since -// `fail` after Collection readiness is health. -function deriveGate(current: Gate, inputs: ReadonlyArray): Gate { - if (current === `error`) return `error` - if (inputs.some((gate) => gate === `error`)) return `error` - if (current === `ready`) return `ready` - if (inputs.every((gate) => gate === `ready`)) return `ready` - return `loading` +// A live query's status follows its sources until it is ready, and never +// leaves `error` or `crashed` within a sync run. A crashed input crashes the +// query at any time, even after its first load failed, as a crash does to a +// Collection; after the query is ready, nothing else changes it. The query +// takes the cause of the input that ended its sync run. +function deriveGate( + current: GateModel, + inputs: ReadonlyArray, +): GateModel { + if (current.gate === `crashed`) return current + const crashed = inputs.find(({ gate }) => gate === `crashed`) + if (crashed) return { gate: `crashed`, cause: crashed.cause } + if (current.gate === `error`) return current + if (current.gate === `ready`) return current + const failed = inputs.find(({ gate }) => gate === `error`) + if (failed) return { gate: `error`, cause: failed.cause } + if (inputs.every(({ gate }) => gate === `ready`)) + return { gate: `ready`, cause: null } + return current } function deriveQueries(shape: Shape, model: Model): void { const gates = (names: ReadonlyArray) => - names.map((name) => model.sources[name].gate) + names.map((name) => model.sources[name]) if (shape === `nested`) { model.inner = deriveGate(model.inner, gates([`A`])) - model.query = deriveGate(model.query, [model.inner, model.sources.B.gate]) + model.query = deriveGate(model.query, [model.inner, model.sources.B]) } else if (shape === `pooled-nested`) { // A pooled view has no gate of its own: it shows its source's. model.inner = deriveGate(model.inner, gates([`A`])) @@ -198,7 +215,7 @@ function deriveQueries(shape: Shape, model: Model): void { // sync run receives nothing more. if ( shape === `include` && - model.query !== `error` && + (model.query.gate === `loading` || model.query.gate === `ready`) && model.sources.B.gate === `ready` ) { model.parentRows = 1 @@ -207,8 +224,15 @@ function deriveQueries(shape: Shape, model: Model): void { } } -/** Apply a step. Returns `throws` when the contract forbids it. */ -function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { +/** + * Apply a step. Returns `throws` when the contract forbids it, and + * `bad-transition` when it forbids `markReady()` because the first load failed. + */ +function applyModel( + shape: Shape, + model: Model, + step: Step, +): `ok` | `throws` | `bad-transition` { if (step.kind === `restart`) { Object.assign(model, freshModel()) return `ok` @@ -216,44 +240,70 @@ function applyModel(shape: Shape, model: Model, step: Step): `ok` | `throws` { // A step on a source this shape does not read has no effect. if (!shapeSources[shape].includes(step.source)) return `ok` const source = model.sources[step.source] - let outcome: `ok` | `throws` = `ok` + let outcome: `ok` | `throws` | `bad-transition` = `ok` + // After a crash, adapter calls do nothing. After fail(), they throw. + const stopped = source.gate === `error` || source.problem?.field === `error` + if (step.kind !== `crash` && source.gate === `crashed`) { + deriveQueries(shape, model) + return outcome + } switch (step.kind) { case `ready`: - if (source.gate === `error`) outcome = `throws` + if (source.gate === `error`) outcome = `bad-transition` + else if (stopped) outcome = `throws` else source.gate = `ready` break case `fail`: - // Before Collection readiness the sync run fails; after it, a final problem. - if (source.gate !== `ready`) source.gate = `error` - else if (!source.problem?.final) - source.problem = { final: true, serial: ++model.serial } + // Before Collection readiness the first load fails; after it, the + // rows stay and the node shows the error. + if (stopped) outcome = `throws` + else if (source.gate !== `ready`) { + source.gate = `error` + source.cause = `${step.source} gave up` + } else + source.problem = { + field: `error`, + message: `${step.source} gave up`, + serial: ++model.serial, + } break case `report`: - if (source.gate !== `ready`) outcome = `throws` - else if (!source.problem?.final) - source.problem = { final: false, serial: ++model.serial } + if (stopped || source.gate !== `ready`) outcome = `throws` + else + source.problem = { + field: `failureReason`, + message: `${step.source} problem`, + serial: ++model.serial, + } break case `healthy`: - if (source.gate !== `ready`) outcome = `throws` - else if (source.problem && !source.problem.final) source.problem = null + if (stopped || source.gate !== `ready`) outcome = `throws` + else source.problem = null break case `crash`: - // From any active status; a Collection already in error stays there. - source.gate = `error` + // From any active status, for good. + if (source.gate !== `crashed`) { + source.gate = `crashed` + source.cause = `${step.source} crashed` + source.problem = null + } break } deriveQueries(shape, model) return outcome } +// A problem's message says which cause the node shows; `serial` (identity +// only) says which report. +type ExpectedProblem = null | { message: string; serial?: number } type ExpectedNode = { alias: string kind: `source` | `live-query` | `include` status: Gate - isError: boolean hasErrors: boolean - error: null | { phase: `initial` | `sync`; final: boolean; serial?: number } - instances?: { total: number; inError: number } + failureReason: ExpectedProblem + error: ExpectedProblem + instances?: { total: number; withErrors: number } sources: Array } @@ -265,22 +315,24 @@ function sourceNode( identity: boolean, kind: ExpectedNode[`kind`] = `source`, ): ExpectedNode { + const reported = (field: `failureReason` | `error`): ExpectedProblem => + source.problem?.field === field + ? { + message: source.problem.message, + ...(identity ? { serial: source.problem.serial } : {}), + } + : null const error = - source.gate === `error` - ? { phase: `initial` as const, final: true } - : source.problem - ? { - phase: `sync` as const, - final: source.problem.final, - ...(identity ? { serial: source.problem.serial } : {}), - } - : null + source.gate === `error` || source.gate === `crashed` + ? { message: source.cause! } + : reported(`error`) + const failureReason = error ? null : reported(`failureReason`) return { alias, kind, status: source.gate, - isError: error !== null, - hasErrors: error !== null, + hasErrors: error !== null || failureReason !== null, + failureReason, error, sources: [], } @@ -288,17 +340,17 @@ function sourceNode( function queryNode( alias: string, - gate: Gate, + { gate, cause }: GateModel, sources: Array, ): ExpectedNode { const error = - gate === `error` ? { phase: `initial` as const, final: true } : null + gate === `error` || gate === `crashed` ? { message: cause! } : null return { alias, kind: `live-query`, status: gate, - isError: error !== null, hasErrors: error !== null || sources.some((node) => node.hasErrors), + failureReason: null, error, sources, } @@ -336,7 +388,7 @@ function expectedTree( source(`b`, B), { ...sourceNode(`a`, A, identity, `include`), - instances: { total: model.parentRows, inError: 0 }, + instances: { total: model.parentRows, withErrors: 0 }, }, ]) case `pooled-nested`: @@ -468,18 +520,15 @@ type PooledView = Subject[`query`] & { // The public tree, projected onto the fields the model owns. function project(node: ErrorTreeNode): ExpectedNode { + const problem = (info: ErrorTreeNode[`error`]): ExpectedProblem => + info ? { message: info.message } : null return { alias: node.alias, kind: node.kind, status: node.status as Gate, - isError: node.isError, hasErrors: node.hasErrors, - error: node.error - ? { - phase: node.error.phase as `initial` | `sync`, - final: node.error.final, - } - : null, + failureReason: problem(node.failureReason), + error: problem(node.error), ...(node.instances ? { instances: node.instances } : {}), sources: node.sources.map(project), } @@ -550,7 +599,7 @@ async function runHistory( }) } const stopStatus = query.on(`status:change`, () => record(`status`)) - const stopHealth = query.on(`health:change`, () => record(`health`)) + const stopHealth = query.on(`errors:change`, () => record(`health`)) stop = () => { stopStatus() stopHealth() @@ -595,7 +644,7 @@ async function runHistory( } params.markReady() } else if (step.kind === `fail`) { - params.markError(new Error(`${step.source} gave up`)) + params.fail(new Error(`${step.source} gave up`)) } else if (step.kind === `healthy`) { params.reportHealthy() } else if (step.kind === `crash`) { @@ -611,9 +660,9 @@ async function runHistory( } await flushPromises() - if (expected === `throws`) { + if (expected !== `ok`) { expect(thrown, `${label}: contract violation throws`).toBeDefined() - if (step.kind === `ready`) { + if (expected === `bad-transition`) { expect(thrown, label).toBeInstanceOf( InvalidCollectionStatusTransitionError, ) @@ -622,9 +671,9 @@ async function runHistory( expect(thrown, `${label}: legal step`).toBeUndefined() } expect(query.status, `${label}: gate`).toBe( - model.query as CollectionStatus, + model.query.gate as CollectionStatus, ) - if (model.query === `ready`) { + if (model.query.gate === `ready`) { expect(size(), `${label}: rows stay while ready`).toBe(1) } const tree = expectedTree(shape, model) @@ -641,8 +690,8 @@ async function runHistory( } previous = next - // Order: `status:change` comes first and `health:change` follows it at - // once, so no `health:change` shows a status that `status:change` has + // Order: `status:change` comes first and `errors:change` follows it at + // once, so no `errors:change` shows a status that `status:change` has // not announced. The last event leaves a reader with the current tree. if (step.kind !== `restart`) { for (const [at, event] of events.entries()) { @@ -699,13 +748,13 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { - 'a non-final problem clears when the sync adapter reports healthy': [ + 'a failure reason clears when the sync adapter reports healthy': [ on(`ready`, `A`), on(`ready`, `B`), on(`report`, `A`), on(`healthy`, `A`), ], - 'markError after Collection readiness stays until a restart': [ + 'fail after Collection readiness keeps the rows, and later reports throw': [ on(`ready`, `A`), on(`ready`, `B`), on(`report`, `A`), @@ -727,17 +776,18 @@ const namedHistories: Record> = { on(`ready`, `A`), on(`ready`, `B`), ], - 'a crash after initial-query readiness ends the query': [ - on(`ready`, `A`), - on(`ready`, `B`), - on(`report`, `A`), - on(`crash`, `A`), - on(`healthy`, `A`), - restart, - on(`ready`, `A`), - on(`ready`, `B`), - ], - 'a health report before Collection readiness throws': [ + 'a crash after initial-query readiness crashes the query, and adapter calls do nothing': + [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + on(`crash`, `A`), + on(`healthy`, `A`), + restart, + on(`ready`, `A`), + on(`ready`, `B`), + ], + 'a report before Collection readiness throws': [ on(`report`, `A`), on(`healthy`, `B`), on(`ready`, `A`), @@ -866,23 +916,23 @@ describe(`request errors`, () => { writeParent(`insert`, 1) markParentsReady() await preload - expect(query.errors.isError).toBe(false) + expect(query.errors.hasErrors).toBe(false) outcomes.push(`fail`) writeParent(`insert`, 2) await flushPromises() expect(query.status).toBe(`ready`) expect(query.errors.error).toMatchObject({ - phase: `request`, - final: false, + message: `child load failed`, }) - expect(children.health.isError).toBe(false) + expect(query.errors.failureReason).toBeNull() + expect(children.errors.hasErrors).toBe(false) // The demand changes again and its new load settles. outcomes.push(`ok`) writeParent(`insert`, 3) await flushPromises() - expect(query.errors.isError).toBe(false) + expect(query.errors.hasErrors).toBe(false) }, )) @@ -896,12 +946,12 @@ describe(`request errors`, () => { outcomes.push(`fail`) writeParent(`insert`, 2) await flushPromises() - expect(query.errors.error?.phase).toBe(`request`) + expect(query.errors.error).toMatchObject({ message: `child load failed` }) writeParent(`delete`, 1) writeParent(`delete`, 2) await flushPromises() - expect(query.errors.isError).toBe(false) + expect(query.errors.hasErrors).toBe(false) expect(query.status).toBe(`ready`) })) @@ -914,10 +964,7 @@ describe(`request errors`, () => { markParentsReady() await flushPromises() expect(query.status).toBe(`error`) - expect(query.errors.error).toMatchObject({ - phase: `initial`, - final: true, - }) + expect(query.errors.error).toMatchObject({ message: `child load failed` }) await expect(preload).rejects.toBeDefined() })) }) @@ -953,7 +1000,7 @@ describe(`root request errors`, () => { console.error = () => {} try { await query.preload() - expect(query.errors.isError).toBe(false) + expect(query.errors.hasErrors).toBe(false) outcomes.push(`fail`) await Promise.resolve( @@ -961,15 +1008,12 @@ describe(`root request errors`, () => { ).catch(() => undefined) await flushPromises() expect(query.status).toBe(`ready`) - expect(query.errors.error).toMatchObject({ - phase: `request`, - final: false, - }) - expect(source.health.isError).toBe(false) + expect(query.errors.error).toMatchObject({ message: `root load failed` }) + expect(source.errors.hasErrors).toBe(false) await query.utils.setWindow({ offset: 8, limit: 2 }) await flushPromises() - expect(query.errors.isError).toBe(false) + expect(query.errors.hasErrors).toBe(false) } finally { console.error = consoleError await query.cleanup() @@ -987,31 +1031,30 @@ describe(`root request errors`, () => { * initial load: every * initial load has settled and every eager source is ready, whether or not it * has `skipInitial`. `skipInitial` only decides whether the initial rows are - * delivered. As for a live query, a source in `error` ends the Effect - * through `onSourceError`, before or after its initial-query readiness. A source reaches `error` - * by an initial error or by a crash. An on-demand source can have its initial - * error after initial-query readiness, because that readiness follows the Effect's settled loads. - * Every other source problem after it is a health error: it does not - * dispose the Effect, and `onHealthChange` receives the error tree, with one - * node per source alias. + * delivered. As for a live query, a crashed source ends the Effect through + * `onSourceError` at any time, and a source whose first load fails ends it + * before its initial-query readiness. An on-demand source can fail its first + * load after that readiness, because the readiness follows the Effect's + * settled loads; like every other source problem after it, that is a sync + * problem on the source's node: it does not dispose the Effect, and + * `onErrorsChange` receives the error tree, with one node per source alias. * * The lane crosses `skipInitial`, the source mode, and when the source fails. * Before initial-query readiness, an eager source has not called `markReady()`, and an * on-demand source still has its first load pending. After it, the - * source calls `markError()`, which is an initial error for an on-demand - * source still in its initial sync and a final health error for an eager - * source after Collection readiness, or it crashes, or it reaches Collection - * readiness (on-demand only) and then reports a health error that is not - * final. Every history also checks that one change reaches - * `onHealthChange` once: no two reported trees are the same object. + * source calls `fail()`, which fails the first load of an on-demand source + * still in its initial sync and sets the `error` of an eager source after + * Collection readiness, or it crashes, or it reaches Collection readiness + * (on-demand only) and then reports a `failureReason`. Every history also checks that one change reaches + * `onErrorsChange` once: no two reported trees are the same object. */ -describe(`effect health`, () => { +describe(`effect errors`, () => { type EffectHistory = { skipInitial: boolean syncMode: `eager` | `on-demand` failure: | `before gate` - | `markError after gate` + | `fail after gate` | `crash after gate` | `report after gate` } @@ -1020,7 +1063,7 @@ describe(`effect health`, () => { ( [ `before gate`, - `markError after gate`, + `fail after gate`, `crash after gate`, `report after gate`, ] as const @@ -1067,14 +1110,14 @@ describe(`effect health`, () => { delivered.push(...events.map((event) => event.key)) }, onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), + onErrorsChange: (errors) => trees.push(errors), }) const consoleError = console.error console.error = () => {} try { await flushPromises() if (failure === `before gate`) { - params.markError(new Error(`gave up`)) + params.fail(new Error(`gave up`)) await flushPromises() // Model: the Effect never reached initial-query readiness, so the failure // ends it. @@ -1097,7 +1140,7 @@ describe(`effect health`, () => { if (failure === `report after gate`) { // An on-demand source reaches its own Collection readiness after the - // Effect's: a status change that must reach onHealthChange once. + // Effect's: a status change that must reach onErrorsChange once. if (syncMode === `on-demand`) params.markReady() await flushPromises() params.reportError(new Error(`stalled`)) @@ -1105,7 +1148,13 @@ describe(`effect health`, () => { expect(effect.disposed).toBe(false) expect(trees.at(-1)).toMatchObject({ hasErrors: true, - sources: [{ alias: `row`, error: { phase: `sync`, final: false } }], + sources: [ + { + alias: `row`, + failureReason: { message: `stalled` }, + error: null, + }, + ], }) expect(new Set(trees).size, `one report per change`).toBe(trees.length) return @@ -1113,14 +1162,13 @@ describe(`effect health`, () => { if (failure === `crash after gate`) { params.collection._lifecycle.crash(new Error(`crashed`)) } else { - params.markError(new Error(`gave up`)) + params.fail(new Error(`gave up`)) } await flushPromises() - // Model: the source reaches `error` by a crash, or by an initial - // error while an on-demand source is still in its initial sync. Either - // ends the Effect. An eager source's markError after Collection - // readiness is a final health error instead. - const ends = failure === `crash after gate` || syncMode === `on-demand` + // Model: only a crash ends the Effect after its gate. A failed first + // load of an on-demand source and an eager source's fail after + // Collection readiness are both the source node's `error`. + const ends = failure === `crash after gate` expect(effect.disposed).toBe(ends) expect(sourceErrors).toHaveLength(ends ? 1 : 0) expect(new Set(trees).size, `one report per change`).toBe(trees.length) @@ -1131,8 +1179,9 @@ describe(`effect health`, () => { sources: [ { alias: `row`, - isError: true, - error: { phase: `sync`, final: true }, + status: syncMode === `on-demand` ? `error` : `ready`, + failureReason: null, + error: { message: `gave up` }, }, ], }) @@ -1147,7 +1196,7 @@ describe(`effect health`, () => { /** * A load that fails after the Effect's gate is a request error, as it is on - * a live query's node: the Effect is not disposed, `onHealthChange` receives + * a live query's node: the Effect is not disposed, `onErrorsChange` receives * a tree whose root node has a `request` error, and a later successful load * of the same source clears it. The parents lead the Effect, and a lazy join * loads the children each new parent row reaches. The lane crosses @@ -1219,7 +1268,7 @@ describe(`effect request errors`, () => { skipInitial, onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onHealthChange: (errors) => trees.push(errors), + onErrorsChange: (errors) => trees.push(errors), }) const consoleError = console.error console.error = () => {} @@ -1236,8 +1285,7 @@ describe(`effect request errors`, () => { expect(effect.disposed).toBe(false) expect(sourceErrors).toEqual([]) expect(trees.at(-1)?.error).toMatchObject({ - phase: `request`, - final: false, + message: `child load failed`, }) expect(trees.at(-1)?.hasErrors).toBe(true) @@ -1261,11 +1309,11 @@ describe(`effect request errors`, () => { * An include node rolls up the include's own per-row child Collections. A * child Collection that fails, here because an index the reader created on * it throws on a new row, counts in `inError` and makes the include node - * `hasErrors`; the live query emits `health:change` for it and keeps its own + * `hasErrors`; the live query emits `errors:change` for it and keeps its own * status. Two includes that read one Collection under the same alias are * two nodes, and each counts only its own children. A healthy child that * starts does not change any node's error state, so it emits no - * `health:change`; otherwise every new parent row would rebuild the tree. + * `errors:change`; otherwise every new parent row would rebuild the tree. */ describe(`include instances`, () => { it(`a failing child Collection counts in its own include only`, async () => { @@ -1311,20 +1359,20 @@ describe(`include instances`, () => { await preload const includes = () => query.errors.sources.slice(1) expect(includes().map((node) => [node.kind, node.instances])).toEqual([ - [`include`, { total: 2, inError: 0 }], - [`include`, { total: 2, inError: 0 }], + [`include`, { total: 2, withErrors: 0 }], + [`include`, { total: 2, withErrors: 0 }], ]) let events = 0 - const stop = query.on(`health:change`, () => { + const stop = query.on(`errors:change`, () => { events++ }) // New parent rows start healthy child Collections. B.write({ id: 3, parentId: 3 }) B.write({ id: 4, parentId: 4 }) await flushPromises() - expect(events, `healthy children emit no health:change`).toBe(0) - expect(includes()[0]!.instances).toEqual({ total: 4, inError: 0 }) + expect(events, `healthy children emit no errors:change`).toBe(0) + expect(includes()[0]!.instances).toEqual({ total: 4, withErrors: 0 }) // One child Collection of `items` fails: its index throws on row 9. class FailingIndex extends BasicIndex { override add(key: number, item: any): void { @@ -1338,20 +1386,20 @@ describe(`include instances`, () => { await flushPromises() stop() - expect(items.status).toBe(`error`) + expect(items.status).toBe(`crashed`) expect(query.status).toBe(`ready`) - expect(events, `health:change relay`).toBeGreaterThan(0) + expect(events, `errors:change relay`).toBeGreaterThan(0) const [itemsNode, sameNode] = includes() - expect(itemsNode!.instances).toEqual({ total: 4, inError: 1 }) + expect(itemsNode!.instances).toEqual({ total: 4, withErrors: 1 }) expect(itemsNode!.hasErrors).toBe(true) - expect(sameNode!.instances).toEqual({ total: 4, inError: 0 }) + expect(sameNode!.instances).toEqual({ total: 4, withErrors: 0 }) expect(sameNode!.hasErrors).toBe(false) expect(query.errors.hasErrors).toBe(true) // Removing the failing child's parent row retires that child, so it // leaves the count and the query reports the change. let retireEvents = 0 - const stopRetire = query.on(`health:change`, () => { + const stopRetire = query.on(`errors:change`, () => { retireEvents++ }) B.write({ id: 1, parentId: 1 }, `delete`) @@ -1360,7 +1408,7 @@ describe(`include instances`, () => { expect(retireEvents, `retiring a child in error relays`).toBeGreaterThan( 0, ) - expect(includes()[0]!.instances).toEqual({ total: 3, inError: 0 }) + expect(includes()[0]!.instances).toEqual({ total: 3, withErrors: 0 }) expect(query.errors.hasErrors).toBe(false) } finally { console.error = consoleError diff --git a/packages/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index 1409efc34b..cc774f9745 100644 --- a/packages/db/tests/truncate-readiness-oracle.test.ts +++ b/packages/db/tests/truncate-readiness-oracle.test.ts @@ -120,7 +120,7 @@ it.each(intents)( }, }) try { - sync.markError(new Error('initial sync failed')) + sync.fail(new Error('initial sync failed')) sync.begin() sync.truncate(markReady === undefined ? undefined : { markReady }) sync.write({ type: 'insert', value: { id: 1, value: 'late' } }) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 3aee54e144..ae5ca58134 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1758,11 +1758,19 @@ function createElectricSync>( write, commit: commitSyncTransaction, markReady, - markError, + fail, truncate, collection, metadata, } = params + // The stream can give up from several places; the first one stops this + // sync run, and fail() may be called once. + let stopped = false + const giveUp = (error: unknown) => { + if (stopped) return + stopped = true + fail(error) + } let commitSequence = 0 const pendingAppliedReceipts = new Map>() const commit = (signal?: AbortSignal): SyncAppliedReceipt => { @@ -1939,13 +1947,13 @@ function createElectricSync>( const failInitialSync = (cause: unknown) => { if (firstSnapshotCommitted) return initialSyncFailedFinally = true - markError(cause) + giveUp(cause) } const settleFirstSnapshot = (isBuffering: boolean, failure?: unknown) => { if (sessionCleanedUp || collection.status !== `loading`) return if (failure !== undefined) { initialSyncFailedFinally = true - markError(failure) + giveUp(failure) } else if (isActiveLifecycle()) { wrappedMarkReady(isBuffering, streamErrorVersion) } else markReady() @@ -1998,7 +2006,7 @@ function createElectricSync>( // retry follows. A retry that succeeds is a success: waiting loads // settle on the snapshot it establishes. // Waiting loads and readiness waits reject with the same cause. - const fail = (cause: unknown) => { + const failWaits = (cause: unknown) => { rejectFullSnapshot(cause) if (collection.status === `loading`) failInitialSync(cause) } @@ -2008,14 +2016,14 @@ function createElectricSync>( try { retry = await shapeOptions.onError(errorParams) } catch (error) { - fail(error) + failWaits(error) throw error } - if (!retry || typeof retry !== `object`) fail(errorParams) + if (!retry || typeof retry !== `object`) failWaits(errorParams) return retry } else { const initialSyncFailed = collection.status === `loading` - fail(errorParams) + failWaits(errorParams) console.error( `An error occurred while syncing collection: ${collection.id}, \n` + (initialSyncFailed @@ -2319,7 +2327,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - markError( + giveUp( new Error( `Electric resume state referenced an unseen row; a full snapshot is required`, ), @@ -2653,7 +2661,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - if (collection.status !== `error`) markError(error) + giveUp(error) }, ) } @@ -2712,7 +2720,7 @@ function createElectricSync>( rejectFullSnapshot(error) if (isActiveLifecycle() && !abortController.signal.aborted) { if (collection.status === `loading`) failInitialSync(error) - else if (collection.status === `ready`) markError(error) + else if (collection.status === `ready`) giveUp(error) } }, ) @@ -2737,7 +2745,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - markError(error) + giveUp(error) }, ) } diff --git a/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts b/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts index 6a4507b50a..aef3d9b84e 100644 --- a/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts @@ -1109,7 +1109,7 @@ fixedCase(`rejects a demand after provider-session restart fails`, async () => { write: () => {}, commit: () => true, markReady: () => {}, - markError: () => {}, + fail: () => {}, truncate: () => {}, metadata: { collection: { get: () => undefined, set: () => {} }, @@ -1156,7 +1156,7 @@ fixedCase(`rejects a demand after provider-session restart fails`, async () => { // a retired session's acceptance settles nothing, whether it resolves or // rejects, and the replacement session's first up-to-date decides readiness. // The driver observes the Collection's readiness boundary (markReady and -// markError) after the retired acceptance settles and after the replacement +// fail) after the retired acceptance settles and after the replacement // delivers its snapshot. fixedCase.each([`resolves`, `rejects`] as const)( `lets only the replacement session decide readiness when a retired acceptance %s`, @@ -1194,7 +1194,7 @@ fixedCase.each([`resolves`, `rejects`] as const)( readiness.push(`ready`) status = `ready` }, - markError: () => { + fail: () => { readiness.push(`error`) status = `error` }, @@ -1262,7 +1262,7 @@ fixedCase( write: () => {}, commit: () => true, markReady: () => {}, - markError: () => {}, + fail: () => {}, truncate: () => {}, metadata: { collection: { get: () => undefined, set: () => {} }, @@ -1327,7 +1327,7 @@ fixedCase( async () => { const options = descriptor(`original`, false, `on-demand`) const write = vi.fn() - const markError = vi.fn() + const fail = vi.fn() const params = { collection: { utils: {}, @@ -1338,7 +1338,7 @@ fixedCase( write, commit: () => true, markReady: () => {}, - markError, + fail, truncate: () => {}, metadata: { collection: { get: () => undefined, set: () => {} }, @@ -1392,7 +1392,7 @@ fixedCase( retiredStream.ignoreUnsubscribe = true retiredStream.send([insert(7, `retired`), upToDate]) expect(write).not.toHaveBeenCalled() - expect(markError).not.toHaveBeenCalled() + expect(fail).not.toHaveBeenCalled() releaseSecond() await atCheckpoint(second, `second cache rotation completes`) expect(streams).toHaveLength(3) diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index beaf07e17b..596e602a4c 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -547,7 +547,7 @@ async function runRejectedDurablePrefixWitness( subscriber!([secondMessage, upToDate]) if (acceptedPrefix) { await atCheckpoint(rejectedAttempt.promise, `second durability rejection`) - await vi.waitFor(() => expect(collection.status).toBe(`error`)) + await vi.waitFor(() => expect(collection.status).toBe(`crashed`)) // A later source callback may arrive after the failed durability turn. // It cannot become part of the accepted prefix or start another write. subscriber!([change(`insert`, 3, `retired`), upToDate]) @@ -576,7 +576,7 @@ async function runRejectedDurablePrefixWitness( // A rejected durability boundary is terminal: it cannot retry, admit the // later source commit, or publish a resume claim for an undurable prefix. // Clearing the accepted prefix on a later rejection is also a wrong rule. - expect(statusAtTerminalCut).toBe(`error`) + expect(statusAtTerminalCut).toBe(`crashed`) expect(durableOffset).toBe(acceptedPrefix ? `21_0` : undefined) expect(rowsFromMap(persistedRows)).toEqual(rowsFromMap(acceptedRows)) }, [() => collection.cleanup()]) @@ -1851,14 +1851,15 @@ type ProcessCommand = /** * After Collection readiness, an Electric sync adapter that gives up reports a - * final health error. Status and rows stay, and only a restart recovers; the + * stopped sync: the Collection's `error`. Status and rows stay, and only a restart recovers; the * reset resume state is what lets that restart refetch. */ function expectGaveUpAfterReadiness( collection: Collection, ) { expect(collection.status).toBe(`ready`) - expect(collection.health.error).toMatchObject({ phase: `sync`, final: true }) + expect(collection.errors.failureReason).toBeNull() + expect(collection.errors.error).toBeTruthy() } type ProcessRuntime = { @@ -1874,7 +1875,7 @@ type ProcessRuntime = { // delivered snapshot or batch carries one, so the model records it there. reachedReadiness: boolean terminalError: boolean - // A give-up after readiness is a final health error, not status `error`. + // A give-up after readiness is the Collection's `error`, not status `error`. terminalAfterReadiness: boolean active: boolean retired: boolean @@ -2165,9 +2166,9 @@ async function runPersistenceFailureCrashOnlyWitness( publicationsBeforeRetiredWork, ), }).toEqual({ - statusAtAdmissionCut: `error`, + statusAtAdmissionCut: `crashed`, publicErrorNameAtAdmissionCut: `PersistedCollectionDurabilityError`, - status: `error`, + status: `crashed`, publicErrorName: `PersistedCollectionDurabilityError`, hasNamedPersistenceSemantics: true, preservesCause: true, diff --git a/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts b/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts index 4d8174da91..f6d2449165 100644 --- a/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts +++ b/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts @@ -570,7 +570,13 @@ async function runRace( }) } } else { - await vi.waitFor(() => expect(collection!.status).toBe(`error`)) + // A schema reset under the persisted store is a local persistence + // failure, so the Collection crashes; the other races fail the load. + await vi.waitFor(() => + expect(collection!.status).toBe( + transition === `schema-reset` ? `crashed` : `error`, + ), + ) await expect(readiness).rejects.toBe(collection._lifecycle.getSyncError()) if (laterKeySetEvidence !== `unchanged`) { expect(collection._lifecycle.getSyncError()).toEqual( diff --git a/packages/powersync-db-collection/src/PowerSyncTransactor.ts b/packages/powersync-db-collection/src/PowerSyncTransactor.ts index 2ed42d5a17..d4d10af88f 100644 --- a/packages/powersync-db-collection/src/PowerSyncTransactor.ts +++ b/packages/powersync-db-collection/src/PowerSyncTransactor.ts @@ -109,11 +109,12 @@ export class PowerSyncTransactor { resolve() } else if ( collection.status === `error` || + collection.status === `crashed` || collection.status === `cleaned-up` ) { unsubscribe() reject( - collection.status === `error` + collection.status !== `cleaned-up` ? (collection._lifecycle.getSyncError() ?? new Error(`Collection failed before readiness`)) : new LoadSubsetOperationAbortedError(), diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index c6e72f109f..3c59da77cf 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -331,7 +331,7 @@ function createPowerSyncCollectionConfig< collection, commit, markReady, - markError, + fail, reportError, reportHealthy, } = params @@ -635,7 +635,7 @@ function createPowerSyncCollectionConfig< error, }) if (collection.status === `loading`) { - markError(error) + fail(error) } }) @@ -836,13 +836,13 @@ function createPowerSyncCollectionConfig< .catch((error) => { // A rebuild may already have removed every active diff trigger, // so rows can grow stale. Later demand rebuilds again, so after - // Collection readiness this is a health error that is not final. + // Collection readiness this is a failureReason, not fail(). if (stopped) { // Cleanup owns the outcome. } else if (collection.status === `ready`) { trackingOutage = true reportError(error) - } else markError(error) + } else if (collection.status === `loading`) fail(error) throw error }) .finally(() => { diff --git a/packages/powersync-db-collection/tests/load-hooks.test.ts b/packages/powersync-db-collection/tests/load-hooks.test.ts index 957471b67a..4bfe417520 100644 --- a/packages/powersync-db-collection/tests/load-hooks.test.ts +++ b/packages/powersync-db-collection/tests/load-hooks.test.ts @@ -1194,7 +1194,7 @@ describe(`Sync Streams`, () => { await new Promise((resolve) => setImmediate(resolve)) expect(cleanupSettled).toBe(false) expect(collection.status).toBe(`ready`) - expect(query.status).toBe(`error`) + expect(query.status).toBe(`crashed`) disposeGate.resolve() const cleanupResult = await cleanupOutcome @@ -1577,7 +1577,7 @@ describe(`Sync Streams`, () => { expect(collection.status).toBe(`ready`) expect(cleanupSettled).toBe(false) - expect(query.status).toBe(`error`) + expect(query.status).toBe(`crashed`) const expected = { status: `rejected`, error: new Error(message) } expect(await preload).toEqual(expected) hook.resolve(cleanupHook) @@ -1602,7 +1602,7 @@ describe(`Sync Streams`, () => { expect(unexpected).toEqual([]) expect(cleanupHook).toHaveBeenCalledOnce() expect(collection.status).toBe(`cleaned-up`) - expect(query.status).toBe(`error`) + expect(query.status).toBe(`crashed`) expect(query.toArray).toEqual([]) expect(collection.size).toBe(0) expect(publications.flat()).toEqual([]) diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 458f89fb03..5d470260cb 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -619,7 +619,7 @@ describe(`On-Demand Sync Mode`, () => { expect.objectContaining({ name: `AssertionError` }), ) expect(collection.status).toBe(`cleaned-up`) - expect(electronics.status).toBe(`error`) + expect(electronics.status).toBe(`crashed`) } }, [ resolvePersistence, @@ -2697,7 +2697,7 @@ describe(`On-Demand Sync Mode`, () => { write, commit, markReady: vi.fn(), - markError: vi.fn(), + fail: vi.fn(), reportError: vi.fn(), reportHealthy: vi.fn(), truncate: vi.fn(), @@ -3249,10 +3249,10 @@ describe(`On-Demand Sync Mode`, () => { ), ).rejects.toBe(failure) // After the first ready, failures don't change status. Instead, they're - // reported as health errors. Status stays ready; data stays available. + // reported as a failure reason. Status stays ready; data stays available. expect(collection.status).toBe(`ready`) - expect(collection.health.isError).toBe(true) - expect(collection.health.error?.message).toMatch( + expect(collection.errors.hasErrors).toBe(true) + expect(collection.errors.failureReason?.message).toMatch( `trigger installation failed`, ) } finally { @@ -3285,11 +3285,9 @@ describe(`On-Demand Sync Mode`, () => { .mockRejectedValueOnce(new Error(`release rebuild failed`)) collection._sync.unloadSubset(first) // The outage keeps the rows and the ready status. The adapter retries - // the rebuild, so it reports a health error that is not final. + // the rebuild, so it reports a failure reason. await vi.waitFor(() => - expect(collection.health.error).toMatchObject({ - phase: `sync`, - final: false, + expect(collection.errors.failureReason).toMatchObject({ message: `release rebuild failed`, }), ) @@ -3308,7 +3306,9 @@ describe(`On-Demand Sync Mode`, () => { expect(trigger.mock.calls.length).toBeGreaterThan(1), ) // A rebuild whose baseline applied reports healthy again. - await vi.waitFor(() => expect(collection.health.isError).toBe(false)) + await vi.waitFor(() => + expect(collection.errors.hasErrors).toBe(false), + ) expect(collection.status).toBe(`ready`) expect([...collection.keys()]).toEqual( change === `unchanged` ? [`retained`] : [], diff --git a/packages/powersync-db-collection/tests/transactor-readiness.test.ts b/packages/powersync-db-collection/tests/transactor-readiness.test.ts index a1e5739d36..1310bfcf8d 100644 --- a/packages/powersync-db-collection/tests/transactor-readiness.test.ts +++ b/packages/powersync-db-collection/tests/transactor-readiness.test.ts @@ -42,7 +42,7 @@ it.each([`cleanup`, `error`, `ready`] as const)( try { expect(collection.status).toBe(`loading`) if (outcome === `cleanup`) await collection.cleanup() - else if (outcome === `error`) collection._lifecycle.markError(failure) + else if (outcome === `error`) collection._lifecycle.fail(failure) else markSourceReady() // Drain promise reactions without waiting on the possibly orphaned wait. for (let turn = 0; turn < 10; turn++) await Promise.resolve() diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 8cd245963f..4f9d39d672 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -321,7 +321,7 @@ export interface QueryCollectionUtils< /** Execute direct writes as one atomic batch. Resolves when the sync commit applies. */ writeBatch: (callback: () => void) => Promise - // Query Observer State (getters). Failures appear in `collection.health` + // Query Observer State (getters). Failures appear in `collection.errors` // and each live query's `errors` tree. /** Check if query is currently fetching (initial or background) */ isFetching: boolean @@ -1114,7 +1114,7 @@ export function queryCollectionOptions( write, commit, markReady, - markError, + fail, reportError, reportHealthy, collection, @@ -1150,8 +1150,8 @@ export function queryCollectionOptions( let errorRevision = 0 const failedResultApplications = new Map() // A failed fetch or result application after Collection readiness is a - // health error that is not final, since TanStack Query fetches again. - // The Collection is healthy again once no observed Query is failing. + // failureReason, since TanStack Query fetches again. It clears once no + // observed Query is failing. const failingQueries = new Set() const reportFailing = (hashedQueryKey: string, error: unknown) => { failingQueries.add(hashedQueryKey) @@ -2759,7 +2759,7 @@ export function queryCollectionOptions( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) - if (collection.status === `loading`) markError(error) + if (collection.status === `loading`) fail(error) else reportFailing(hashedQueryKey, error) }, ) @@ -3127,10 +3127,10 @@ export function queryCollectionOptions( // A failure before the first successful snapshot leaves no usable // collection state. Later refetch failures keep the last ready - // snapshot available and report health. + // snapshot available and report a failureReason. // A re-notified error is the same failure, so it is not reported // again. - if (collection.status === `loading`) markError(result.error) + if (collection.status === `loading`) fail(result.error) else if (isNewError) reportFailing(hashedQueryKey, result.error) } } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index c09e6d1a94..0d93aeba0a 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -15,17 +15,20 @@ import type { QueryFunctionContext } from '@tanstack/query-core' import type { LoadSubsetOptions, SyncMetadataApi } from '@tanstack/db' /** - * The Query failure a Collection reports: its initial error while it is in - * `error`, or the cause of its health error after readiness. + * The Query failure a Collection reports: its failed first load while it is + * in `error`, or the cause of its failure reason after readiness. */ function reportedError(collection: { status: string - health: { error: { cause: unknown } | null } + errors: { + failureReason: { cause: unknown } | null + error: { cause: unknown } | null + } _lifecycle: { getSyncError: () => unknown } }): unknown { return collection.status === `error` ? collection._lifecycle.getSyncError() - : collection.health.error?.cause + : (collection.errors.failureReason ?? collection.errors.error)?.cause } /** @@ -1173,7 +1176,7 @@ async function expectForeignOwnerReleaseKeepsInFlightSignal(): Promise { } /** - * After Collection readiness, a failed refetch is a health error that is not + * After Collection readiness, a failed refetch is a failure reason, which is not * final, because TanStack Query fetches again, and the status and rows stay. * The next successful application clears it. The model is that one rule; it * does not read the adapter's per-key bookkeeping. @@ -1200,34 +1203,34 @@ async function expectRefetchFailureIsHealth(): Promise { ) const observe = () => ({ status: collection.status, - health: collection.health.error && { - phase: collection.health.error.phase, - final: collection.health.error.final, - cause: collection.health.error.cause, - }, + failureReason: collection.errors.failureReason?.cause ?? null, + error: collection.errors.error?.cause ?? null, rows: collection.toArray.map((row) => row.id), }) try { await collection.preload() expect(observe()).toEqual({ status: `ready`, - health: null, + failureReason: null, + error: null, rows: [`first`], }) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.health.isError).toBe(true)) + await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(true)) expect(observe()).toEqual({ status: `ready`, - health: { phase: `sync`, final: false, cause: error }, + failureReason: error, + error: null, rows: [`first`], }) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.health.isError).toBe(false)) + await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(false)) expect(observe()).toEqual({ status: `ready`, - health: null, + failureReason: null, + error: null, rows: [`second`], }) } finally { @@ -1238,9 +1241,9 @@ async function expectRefetchFailureIsHealth(): Promise { } /** - * Collection health covers every Query the Collection observes. With two - * on-demand subsets, one failing refetch is a health error even when the - * other subset's refetch succeeds; the Collection is healthy again only once + * A Collection's failure reason covers every Query it observes. With two + * on-demand subsets, one failing refetch is a failure reason even when the + * other subset's refetch succeeds; it clears only once * no Query is failing. The model is the set of failing subsets. */ async function expectHealthWaitsForEveryFailingQuery(): Promise { @@ -1270,22 +1273,22 @@ async function expectHealthWaitsForEveryFailingQuery(): Promise { await collection._sync.loadSubset({ limit: 1 }) await collection._sync.loadSubset({ limit: 2 }) expect(collection.status).toBe(`ready`) - expect(collection.health.isError).toBe(false) + expect(collection.errors.hasErrors).toBe(false) failing.add(1) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.health.isError).toBe(true)) - expect(collection.health.error?.message).toBe(`subset 1 failed`) + await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(true)) + expect(collection.errors.failureReason?.message).toBe(`subset 1 failed`) // A subset that starts after the failure and succeeds does not make the // failing subset healthy. await collection._sync.loadSubset({ limit: 3 }) await vi.waitFor(() => expect(collection.has(`row-3`)).toBe(true)) - expect(collection.health.error?.message).toBe(`subset 1 failed`) + expect(collection.errors.failureReason?.message).toBe(`subset 1 failed`) failing.clear() await collection.utils.refetch() - await vi.waitFor(() => expect(collection.health.isError).toBe(false)) + await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(false)) expect(collection.status).toBe(`ready`) } finally { loggedError.mockRestore() @@ -1407,7 +1410,7 @@ async function expectLateDependentObservesInitialFailure(): Promise { async function expectEveryFailedSourceToRecover(): Promise { const createControlledSource = (id: string) => { - let fail: () => void = () => { + let failSync: () => void = () => { throw new Error(`Source '${id}' has not started`) } let recover: (row: Row) => void = (_row) => { @@ -1420,8 +1423,8 @@ async function expectEveryFailedSourceToRecover(): Promise { autoIndex: `eager`, defaultIndexType: BasicIndex, sync: { - sync: ({ begin, write, commit, markReady, markError }) => { - fail = markError + sync: ({ begin, write, commit, markReady, fail }) => { + failSync = () => fail(new Error(`Source '${id}' failed`)) recover = (row) => { begin() write({ type: `insert`, value: row }) @@ -1433,7 +1436,7 @@ async function expectEveryFailedSourceToRecover(): Promise { }) return { collection, - fail: () => fail(), + fail: () => failSync(), recover: (row: Row) => recover(row), } } diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 59d33052df..5a291c229a 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -44,17 +44,20 @@ import type { NonSingleResult } from '../../db/src/types.js' import type { QueryCollectionUtils } from '../src/query.js' /** - * The Query failure a Collection reports: its initial error while it is in - * `error`, or the cause of its health error after readiness. + * The Query failure a Collection reports: its failed first load while it is + * in `error`, or the cause of its failure reason after readiness. */ function reportedError(collection: { status: string - health: { error: { cause: unknown } | null } + errors: { + failureReason: { cause: unknown } | null + error: { cause: unknown } | null + } _lifecycle: { getSyncError: () => unknown } }): unknown { return collection.status === `error` ? collection._lifecycle.getSyncError() - : collection.health.error?.cause + : (collection.errors.failureReason ?? collection.errors.error)?.cause } /** @@ -13122,7 +13125,7 @@ it(`reports a failed Query cache rotation as a terminal Collection error`, async await expect(outcome).rejects.toBe(failure) // A refused rotation is a persistence failure, so the Collection crashes // even after Collection readiness, and later demand rejects with its cause. - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) await expect(collection._sync.loadSubset({ limit: 2 })).rejects.toBe(failure) }) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index ad1e234ab3..f9f3a12956 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -44,17 +44,20 @@ import type { import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' /** - * The Query failure a Collection reports: its initial error while it is in - * `error`, or the cause of its health error after readiness. + * The Query failure a Collection reports: its failed first load while it is + * in `error`, or the cause of its failure reason after readiness. */ function reportedError(collection: { status: string - health: { error: { cause: unknown } | null } + errors: { + failureReason: { cause: unknown } | null + error: { cause: unknown } | null + } _lifecycle: { getSyncError: () => unknown } }): unknown { return collection.status === `error` ? collection._lifecycle.getSyncError() - : collection.health.error?.cause + : (collection.errors.failureReason ?? collection.errors.error)?.cause } interface TestItem { @@ -433,7 +436,7 @@ describe(`QueryCollection`, () => { expect(() => malformedCollection.startSyncImmediate()).toThrow( /expected null or a complete capability object.*forward metadata\.persistence unchanged/i, ) - expect(malformedCollection.status).toBe(`error`) + expect(malformedCollection.status).toBe(`crashed`) expect(queryFn).not.toHaveBeenCalled() await malformedCollection.cleanup() }) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index c2e1a5d1f4..0fd35357a4 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -93,8 +93,13 @@ export type UseLiveInfiniteQueryReturnWithCollection< isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean isCleanedUp: boolean + /** The data is shown, and a sync problem or failed page load is in `errors`. */ + isSyncError: boolean isEnabled: true pages: Array> pageParams: Array @@ -341,6 +346,7 @@ export function useLiveInfiniteQuery( ) && rendered.collection.status !== `cleaned-up` && rendered.collection.status !== `error` && + rendered.collection.status !== `crashed` && liveQueryWindowMatches(rendered.collection, requiredLimit) ) { // An earlier render built this collection and nothing has committed @@ -460,8 +466,10 @@ export function useLiveInfiniteQuery( isPersistedReady: snapshot.isPersistedReady, persistedError: snapshot.persistedError, isIdle: snapshot.isIdle, - isError: snapshot.isError, + isLoadingError: snapshot.isLoadingError, + isCrashed: snapshot.isCrashed, isCleanedUp: snapshot.isCleanedUp, + isSyncError: snapshot.isSyncError, collection: snapshot.collection as EnabledLiveQueryReturn[`collection`], isEnabled: snapshot.isEnabled, diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 9dbe11b4d7..9388cc0b07 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -371,12 +371,12 @@ export function warnUnhashableDerivedIdentity( * * @example * // Handle loading and error states - * const { data, isLoading, isError, status } = useLiveQuery({ + * const { data, isLoading, isLoadingError, status } = useLiveQuery({ * query: (q) => q.from({ todos: todoCollection }) * }) * * if (isLoading) return
Loading...
- * if (isError) return
Error: {status}
+ * if (isLoadingError) return
Error: {status}
* * return ( *
    @@ -384,7 +384,7 @@ export function warnUnhashableDerivedIdentity( *
* ) */ -/** Status, persistence, and health fields every `useLiveQuery` result has. */ +/** Status, persistence, and error fields every `useLiveQuery` result has. */ type UseLiveQueryFlags = { isLoading: boolean isReady: boolean @@ -392,8 +392,13 @@ type UseLiveQueryFlags = { isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean isCleanedUp: boolean + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: boolean errors: ErrorTreeNode | undefined } @@ -508,12 +513,12 @@ export function useLiveQuery< * * @example * // Handle all states uniformly - * const { data, isLoading, isReady, isError } = useLiveQuery({ + * const { data, isLoading, isReady, isLoadingError } = useLiveQuery({ * query: (q) => q.from({ items: itemCollection }) * }) * * if (isLoading) return
Loading...
- * if (isError) return
Something went wrong
+ * if (isLoadingError) return
Something went wrong
* if (!isReady) return
Preparing...
* * return
{data.length} items loaded
@@ -586,10 +591,10 @@ export function useLiveQuery( * * @example * // Handle states consistently - * const { data, isLoading, isError } = useLiveQuery(sharedCollection) + * const { data, isLoading, isLoadingError } = useLiveQuery(sharedCollection) * * if (isLoading) return
Loading...
- * if (isError) return
Error loading data
+ * if (isLoadingError) return
Error loading data
* * return
{data.map(item => )}
*/ @@ -882,10 +887,10 @@ function useLiveQueryImpl( collection, ), ) - const removeErrorListener = collection.on(`status:error`, () => { - // Keep the failed collection through React's immediate retry so - // the hook can throw its actual load error to an ErrorBoundary. - // Retire it afterward so a later boundary reset starts fresh. + // Keep a failed or crashed collection through React's immediate + // retry so the hook can throw its actual error to an ErrorBoundary. + // Retire it afterward so a later boundary reset starts fresh. + const retireAfterRetry = () => setTimeout( () => releaseSuspenseCollection( @@ -895,12 +900,20 @@ function useLiveQueryImpl( ), 0, ) - }) + const removeErrorListener = collection.on( + `status:error`, + retireAfterRetry, + ) + const removeCrashListener = collection.on( + `status:crashed`, + retireAfterRetry, + ) const entry: SuspenseCollectionEntry = { collection, removeStatusListeners: () => { removeCleanupListener() removeErrorListener() + removeCrashListener() }, } suspenseCollections.set(suspenseKey, entry) diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 95295e8e5b..d9077abc77 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -288,13 +288,15 @@ export function useLiveSuspenseQuery( throw initialRenderError.error } - // Only throw errors during initial load (before first ready) - // After success, errors surface as stale data (matches TanStack Query behavior) + // A failed first load throws only before the first ready; later sync + // problems surface in `errors` with the data kept (as in TanStack Query). + // A crash throws at any time: the Collection can no longer serve reads. if ( - collectionStatus === `error` && - !hasBeenReadyRef.current && - (result.persistedStatus === `unavailable` || - result.persistedStatus === `error`) + collectionStatus === `crashed` || + (collectionStatus === `error` && + !hasBeenReadyRef.current && + (result.persistedStatus === `unavailable` || + result.persistedStatus === `error`)) ) { promiseRef.current = null // TODO: Once collections hold a reference to their last error object (#671), @@ -306,7 +308,8 @@ export function useLiveSuspenseQuery( !hasBeenReadyRef.current && (result.isLoading || result.isIdle || - result.isError || + result.isLoadingError || + result.isCrashed || (collectionStatus === `error` && result.persistedStatus !== `unavailable`)) ) { diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index c96296758e..930f010076 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -27,7 +27,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' -import { makeHealthSource } from '../../db/tests/conformance/health-source' +import { makeSyncSource } from '../../db/tests/conformance/sync-source' +import { makeErrorSource as makeSharedErrorSource } from '../../db/tests/conformance/error-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import type { RenderHookResult } from '@testing-library/react' import type { @@ -100,28 +101,12 @@ function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { return { collection } } -function makeErrorSource() { - const expectedError = new Error(`conformance: sync failure`) - let startup: { returned: true } | { returned: false; error: unknown } = { - returned: true, - } - const collection = createCollection<{ id: string }>({ - id: `conformance-react-err-${sourceSeq++}`, - getKey: (r) => r.id, - startSync: false, - sync: { - sync: () => { - throw expectedError - }, - }, - }) - // Starting sync throws → engine catches and sets status to `error`. - try { - collection.startSyncImmediate() - } catch (error) { - startup = { returned: false, error } - } - return { collection, expectedError, startup } +function makeErrorSource(kind: `fail` | `crash`) { + return makeSharedErrorSource( + createCollection, + `conformance-react-err-${sourceSeq++}`, + kind, + ) } function mount(build: QueryBuild) { @@ -179,7 +164,9 @@ function makeHandle(hook: RenderHookResult) { persistedStatus: r?.persistedStatus, isPersistedReady: r?.isPersistedReady, persistedError: r?.persistedError, - isError: r?.isError, + isLoadingError: r?.isLoadingError, + isCrashed: r?.isCrashed, + isSyncError: r?.isSyncError, // Read react-db's real `isEnabled` field so the suite catches a broken // one (deriving from status would mask it). isEnabled: r?.isEnabled, @@ -214,8 +201,8 @@ const reactDriver: LiveQueryDriver = { createCollection, `conformance-react-lagging-${sourceSeq++}`, ), - makeHealthSource: (data: ReadonlyArray) => - makeHealthSource( + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( createCollection, `conformance-react-health-${sourceSeq++}`, data, @@ -307,7 +294,9 @@ it(`preserves raw result types through the actual driver reader`, () => { state: new Map(), status: `disabled`, isReady: false, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, } const hook = renderHook(() => raw) @@ -317,9 +306,9 @@ it(`preserves raw result types through the actual driver reader`, () => { expect(healthy.data).toBe(raw.data) expect(healthy.state).toBe(raw.state) expect(healthy.isReady).toBe(false) - expect(healthy.isError).toBe(false) + expect(healthy.isLoadingError).toBe(false) expect(healthy.isEnabled).toBe(false) - for (const key of [`status`, `isReady`, `isError`, `isEnabled`]) { + for (const key of [`status`, `isReady`, `isLoadingError`, `isEnabled`]) { const original = raw[key] delete raw[key] expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 50303827ad..5b3d1a2d23 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -94,7 +94,7 @@ function makeHandle( pageParams: result.pageParams, hasNextPage: result.hasNextPage, isFetchingNextPage: result.isFetchingNextPage, - isError: result.isError, + isLoadingError: result.isLoadingError, status: result.status, collection: result.collection, } diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index f0f7f049d9..06b390be31 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -228,7 +228,7 @@ describe(`useLiveInfiniteQuery`, () => { await waitFor(() => { // A failed page request after readiness does not change the status. expect(result.current.isFetchingNextPage).toBe(false) - expect(result.current.isError).toBe(false) + expect(result.current.isLoadingError).toBe(false) }) expect(result.current.pages).toHaveLength(1) expect(result.current.hasNextPage).toBe(true) diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 1cfcbd00ee..80f8848d64 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1009,7 +1009,7 @@ describe(`Query Collections`, () => { }) methods.commit() methods.markReady() - failSource = () => methods.markError(new Error(`source sync failed`)) + failSource = () => methods.fail(new Error(`source sync failed`)) }, }, }) @@ -1028,7 +1028,7 @@ describe(`Query Collections`, () => { act(() => failSource()) await waitFor(() => expect(result.current.errors?.hasErrors).toBe(true)) expect(result.current.status).toBe(`ready`) - expect(result.current.isError).toBe(false) + expect(result.current.isLoadingError).toBe(false) expect(result.current.data[0]?.text).toBe(`Todo 1`) await act(async () => { @@ -2236,7 +2236,7 @@ describe(`Query Collections`, () => { expect(result.current.isLoading).toBe(false) expect(result.current.isReady).toBe(true) expect(result.current.isIdle).toBe(false) - expect(result.current.isError).toBe(false) + expect(result.current.isLoadingError).toBe(false) expect(result.current.isCleanedUp).toBe(false) // Enable the query @@ -2275,7 +2275,7 @@ describe(`Query Collections`, () => { expect(result.current.isLoading).toBe(false) expect(result.current.isReady).toBe(true) expect(result.current.isIdle).toBe(false) - expect(result.current.isError).toBe(false) + expect(result.current.isLoadingError).toBe(false) expect(result.current.isCleanedUp).toBe(false) }) @@ -2313,7 +2313,7 @@ describe(`Query Collections`, () => { expect(result.current.isLoading).toBe(false) expect(result.current.isReady).toBe(true) expect(result.current.isIdle).toBe(false) - expect(result.current.isError).toBe(false) + expect(result.current.isLoadingError).toBe(false) expect(result.current.isCleanedUp).toBe(false) // Enable the query diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 2b4d56aa55..ea63320f23 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -389,14 +389,14 @@ describe(`useLiveSuspenseQuery`, () => { write: (row: Person) => void commit: () => void markReady: () => void - markError: (error: unknown) => void + fail: (error: unknown) => void } | undefined const source = createCollection({ id: `suspense-persisted-${outcome}`, getKey: (row) => row.id, sync: { - sync: ({ begin, write, commit, markReady, markError }) => { + sync: ({ begin, write, commit, markReady, fail }) => { sync = { begin, write: (row) => write({ type: `insert`, value: row }), @@ -404,7 +404,7 @@ describe(`useLiveSuspenseQuery`, () => { commit() }, markReady, - markError, + fail, } return {} }, @@ -463,7 +463,7 @@ describe(`useLiveSuspenseQuery`, () => { expect(view.getByText(`Waiting for network`)).toBeTruthy() await act(() => { if (outcome === `ready`) sync!.markReady() - else sync!.markError(new Error(`network failed`)) + else sync!.fail(new Error(`network failed`)) }) await waitFor(() => expect(view.getByText(`John Doe`)).toBeTruthy()) expect(source.status).toBe(outcome) diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index a5140b2031..ac071df346 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -126,7 +126,7 @@ export function rxdbCollectionOptions( type SyncParams = Parameters[`sync`]>[0] const sync: SyncConfig = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, collection } = params + const { begin, write, commit, markReady, fail, collection } = params let initialFetchComplete = false async function initialFetch() { @@ -280,7 +280,7 @@ export function rxdbCollectionOptions( void start().catch((error: unknown) => { stopOngoingFetch() if (collection.status === `loading`) { - markError(error) + fail(error) } }) diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index 70607c3e22..bf4fdf6720 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -241,13 +241,13 @@ describe(`RxDB Integration`, () => { .mockReturnValueOnce(bufferedApplied.promise) .mockReturnValueOnce(laterLiveApplied.promise) const markReady = vi.fn() - const markError = vi.fn() + const fail = vi.fn() const cleanup = options.sync.sync({ begin, write, commit, markReady, - markError, + fail, collection: { status: `loading` }, } as never) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 6d2f7874e8..511f6eda15 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -84,7 +84,7 @@ type InferConditionalResultType = * *
Loading...
*
- * + * *
Error: {todosQuery.status}
*
* @@ -109,7 +109,7 @@ type InferConditionalResultType = *
* ) */ -/** Status, persistence, and health fields every `useLiveQuery` result has. */ +/** Status, persistence, and error fields every `useLiveQuery` result has. */ type UseLiveQueryFlags = { isLoading: boolean isReady: boolean @@ -117,8 +117,13 @@ type UseLiveQueryFlags = { isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean isCleanedUp: boolean + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: boolean errors: ErrorTreeNode | undefined } @@ -183,7 +188,7 @@ export function useLiveQuery( * *
Loading...
*
- * + * *
Something went wrong
*
* @@ -235,7 +240,7 @@ export function useLiveQuery( * *
Loading...
*
- * + * *
Error loading data
*
* @@ -575,16 +580,26 @@ export function useLiveQuery( return status() === `idle` }, }, - isError: { + isLoadingError: { get() { return status() === `error` }, }, + isCrashed: { + get() { + return status() === `crashed` + }, + }, isCleanedUp: { get() { return status() === `cleaned-up` }, }, + isSyncError: { + get() { + return status() === `ready` && errors()?.hasErrors === true + }, + }, errors: { get() { return errors() diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 9b5d62cc49..d46b6e252d 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -28,7 +28,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' -import { makeHealthSource } from '../../db/tests/conformance/health-source' +import { makeSyncSource } from '../../db/tests/conformance/sync-source' +import { makeErrorSource as makeSharedErrorSource } from '../../db/tests/conformance/error-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' import type { @@ -103,27 +104,12 @@ function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { return { collection } } -function makeErrorSource() { - const expectedError = new Error(`conformance: sync failure`) - let startup: { returned: true } | { returned: false; error: unknown } = { - returned: true, - } - const collection = createCollection<{ id: string }>({ - id: `conformance-solid-err-${sourceSeq++}`, - getKey: (r) => r.id, - startSync: false, - sync: { - sync: () => { - throw expectedError - }, - }, - }) - try { - collection.startSyncImmediate() - } catch (error) { - startup = { returned: false, error } - } - return { collection, expectedError, startup } +function makeErrorSource(kind: `fail` | `crash`) { + return makeSharedErrorSource( + createCollection, + `conformance-solid-err-${sourceSeq++}`, + kind, + ) } async function settle() { @@ -145,7 +131,9 @@ function makeHandle( persistedStatus: result?.persistedStatus, isPersistedReady: result?.isPersistedReady, persistedError: result?.persistedError, - isError: result?.isError, + isLoadingError: result?.isLoadingError, + isCrashed: result?.isCrashed, + isSyncError: result?.isSyncError, // solid-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result?.status !== `disabled`, errors: result?.errors, @@ -231,8 +219,8 @@ const solidDriver: LiveQueryDriver = { createCollection, `conformance-solid-lagging-${sourceSeq++}`, ), - makeHealthSource: (data: ReadonlyArray) => - makeHealthSource( + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( createCollection, `conformance-solid-health-${sourceSeq++}`, data, @@ -246,7 +234,7 @@ const solidDriver: LiveQueryDriver = { mountDisabled, // solid-db routes errors through its createResource/Suspense path: reading an // errored query throws (CollectionStateError) for an to catch, - // rather than exposing a readable isError flag. That's a framework idiom, not a + // rather than exposing a readable isLoadingError flag. That's a framework idiom, not a // gap — the error-status scenario is parametrized to assert it via the boundary. errorSurface: `throw`, knownGaps: [], @@ -302,7 +290,9 @@ it(`preserves raw result types through the actual driver reader`, () => { state: new Map(), status: `disabled`, isReady: false, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, } const handle = makeHandle( @@ -314,9 +304,9 @@ it(`preserves raw result types through the actual driver reader`, () => { expect(healthy.data).toBe(raw.data) expect(healthy.state).toBe(raw.state) expect(healthy.isReady).toBe(false) - expect(healthy.isError).toBe(false) + expect(healthy.isLoadingError).toBe(false) expect(healthy.isEnabled).toBe(false) - for (const key of [`status`, `isReady`, `isError`]) { + for (const key of [`status`, `isReady`, `isLoadingError`]) { const original = raw[key] delete raw[key] expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 83a3c4c219..b2600f3150 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -2149,14 +2149,14 @@ describe(`Query Collections`, () => { write: (row: Person) => void commit: () => void markReady: () => void - markError: (error: unknown) => void + fail: (error: unknown) => void } | undefined const source = createCollection({ id: `solid-persisted-${outcome}`, getKey: (row) => row.id, sync: { - sync: ({ begin, write, commit, markReady, markError }) => { + sync: ({ begin, write, commit, markReady, fail }) => { sync = { begin, write: (row) => write({ type: `insert`, value: row }), @@ -2164,7 +2164,7 @@ describe(`Query Collections`, () => { commit() }, markReady, - markError, + fail, } return {} }, @@ -2220,7 +2220,7 @@ describe(`Query Collections`, () => { } else { expect(view.getByTestId(`local-waiting`)).toBeTruthy() if (outcome === `ready`) sync!.markReady() - else sync!.markError(new Error(`network failed`)) + else sync!.fail(new Error(`network failed`)) } const content = await view.findByTestId(`local-person`) expect(content.textContent).toBe(`John Doe`) diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 51a3d94d73..f55849f18f 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -262,12 +262,18 @@ export function useLiveInfiniteQuery( get isIdle() { return snapshot.isIdle }, - get isError() { - return snapshot.isError + get isLoadingError() { + return snapshot.isLoadingError + }, + get isCrashed() { + return snapshot.isCrashed }, get isCleanedUp() { return snapshot.isCleanedUp }, + get isSyncError() { + return snapshot.isSyncError + }, get pages() { return snapshot.pages as Array[number]>> }, diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 99f0e69e82..e7ca1c5428 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -52,7 +52,9 @@ function snapshotRows(data: unknown): Array { * @property isLoading - True while initial query data is loading * @property isReady - True when query has received first data and is ready * @property isIdle - True when query hasn't started yet - * @property isError - True when query encountered an error + * @property isLoadingError - True when the first load failed + * @property isCrashed - True when local storage or an invariant broke + * @property isSyncError - True when data is shown and `errors` has a problem * @property isCleanedUp - True when query has been cleaned up */ export interface UseLiveQueryReturn> { @@ -66,8 +68,13 @@ export interface UseLiveQueryReturn> { isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean isCleanedUp: boolean + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: boolean errors: ErrorTreeNode | undefined } @@ -100,8 +107,13 @@ export interface UseLiveQueryReturnWithCollection< isPersistedReady: boolean persistedError: unknown | undefined isIdle: boolean - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean isCleanedUp: boolean + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: boolean errors: ErrorTreeNode | undefined } @@ -172,8 +184,8 @@ function toValue(value: MaybeGetter): T { * const query = useLiveQuery((q) => * q.from({ todos: todosCollection }) * ) - * const { data, isLoading, isError } = $derived(query) - * // Now data, isLoading, and isError maintain reactivity + * const { data, isLoading, isLoadingError } = $derived(query) + * // Now data, isLoading, and isLoadingError maintain reactivity * * @example * // Join pattern @@ -198,7 +210,7 @@ function toValue(value: MaybeGetter): T { * // In template: * // {#if todosQuery.isLoading} * //
Loading...
- * // {:else if todosQuery.isError} + * // {:else if todosQuery.isLoadingError} * //
Error: {todosQuery.status}
* // {:else} * //
    @@ -254,7 +266,7 @@ export function useLiveQuery( * // In template: * // {#if itemsQuery.isLoading} * //
    Loading...
    - * // {:else if itemsQuery.isError} + * // {:else if itemsQuery.isLoadingError} * //
    Something went wrong
    * // {:else if !itemsQuery.isReady} * //
    Preparing...
    @@ -303,7 +315,7 @@ export function useLiveQuery( * // In template: * // {#if queryResult.isLoading} * //
    Loading...
    - * // {:else if queryResult.isError} + * // {:else if queryResult.isLoadingError} * //
    Error loading data
    * // {:else} * // {#each queryResult.data as item (item.id)} @@ -570,12 +582,18 @@ export function useLiveQuery( get isIdle() { return status === `idle` }, - get isError() { + get isLoadingError() { return status === `error` }, + get isCrashed() { + return status === `crashed` + }, get isCleanedUp() { return status === `cleaned-up` }, + get isSyncError() { + return status === `ready` && errors?.hasErrors === true + }, get errors() { return errors }, diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index cb5b4abf0c..264fd01697 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -27,7 +27,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery.svelte.js' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' -import { makeHealthSource } from '../../db/tests/conformance/health-source' +import { makeSyncSource } from '../../db/tests/conformance/sync-source' +import { makeErrorSource as makeSharedErrorSource } from '../../db/tests/conformance/error-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import type { ConformanceResult, @@ -101,27 +102,12 @@ function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { return { collection } } -function makeErrorSource() { - const expectedError = new Error(`conformance: sync failure`) - let startup: { returned: true } | { returned: false; error: unknown } = { - returned: true, - } - const collection = createCollection<{ id: string }>({ - id: `conformance-svelte-err-${sourceSeq++}`, - getKey: (r) => r.id, - startSync: false, - sync: { - sync: () => { - throw expectedError - }, - }, - }) - try { - collection.startSyncImmediate() - } catch (error) { - startup = { returned: false, error } - } - return { collection, expectedError, startup } +function makeErrorSource(kind: `fail` | `crash`) { + return makeSharedErrorSource( + createCollection, + `conformance-svelte-err-${sourceSeq++}`, + kind, + ) } async function settle() { @@ -142,7 +128,9 @@ function makeHandle(getQuery: () => any, dispose: () => void): LiveQueryHandle { persistedStatus: query?.persistedStatus, isPersistedReady: query?.isPersistedReady, persistedError: query?.persistedError, - isError: query?.isError, + isLoadingError: query?.isLoadingError, + isCrashed: query?.isCrashed, + isSyncError: query?.isSyncError, errors: query?.errors, // svelte-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: query?.status !== `disabled`, @@ -222,8 +210,8 @@ const svelteDriver: LiveQueryDriver = { createCollection, `conformance-svelte-lagging-${sourceSeq++}`, ), - makeHealthSource: (data: ReadonlyArray) => - makeHealthSource( + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( createCollection, `conformance-svelte-health-${sourceSeq++}`, data, @@ -246,7 +234,9 @@ it(`preserves raw result types through the actual driver reader`, () => { state: new Map(), status: `disabled`, isReady: false, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, } const handle = makeHandle( @@ -258,9 +248,9 @@ it(`preserves raw result types through the actual driver reader`, () => { expect(healthy.data).toBe(raw.data) expect(healthy.state).toBe(raw.state) expect(healthy.isReady).toBe(false) - expect(healthy.isError).toBe(false) + expect(healthy.isLoadingError).toBe(false) expect(healthy.isEnabled).toBe(false) - for (const key of [`status`, `isReady`, `isError`]) { + for (const key of [`status`, `isReady`, `isLoadingError`]) { const original = raw[key] delete raw[key] expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts index 689a129380..f8681aab1d 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -113,7 +113,7 @@ function makeHandle( pageParams: result.pageParams, hasNextPage: result.hasNextPage, isFetchingNextPage: result.isFetchingNextPage, - isError: result.isError, + isLoadingError: result.isLoadingError, status: result.status, collection: result.collection, } diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index cf96a15843..5ed73b985a 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -185,7 +185,7 @@ export function trailBaseCollectionOptions< type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, collection } = params + const { begin, write, commit, markReady, fail, collection } = params let cancelled = false let periodicCleanupTask: ReturnType | undefined const cleanup = () => { @@ -397,13 +397,19 @@ export function trailBaseCollectionOptions< } } - // The adapter does not resubscribe, so it gives up when its stream - // ends; a failed sync run already reported why it ended. + // The adapter does not resubscribe, so it stops when its stream ends; + // the first end reports why, and fail() may be called once. + let streamEnded = false function reportStreamEnd(error: unknown, closed = false) { if (cancelled) return - if (collection.status === `loading` || collection.status === `ready`) - markError(error) - else if (!closed) console.error(`TrailBase subscription failed`, error) + if ( + !streamEnded && + (collection.status === `loading` || collection.status === `ready`) + ) { + streamEnded = true + fail(error) + } else if (!closed) + console.error(`TrailBase subscription failed`, error) } async function start() { diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index c575d146ba..c41cd8140b 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -13,7 +13,7 @@ import type { Event, ListResponse } from 'trailbase' * on-demand sync run does not claim an initial list. Stream events update an independent * key/value relation. Startup failure rejects; any later end of the stream * that the adapter did not cancel keeps the last rows and the `ready` status - * and reports a final health error, because the adapter does not resubscribe. + * and reports the Collection's `error`, because the adapter does not resubscribe. * A failure is that error's `cause`. Cleanup clears rows, cancels work, and fences * late results from prior sync runs after restart. * @@ -354,16 +354,16 @@ async function checkLifecycle( const isFailure = plan.ending === `read-error` || plan.ending === `parse-error` if (!plan.immediate && plan.ending !== `cleanup`) { - expect(collection.health, `${epoch}:${plan.ending} health`).toEqual( - { - isError: true, - error: expect.objectContaining({ - phase: `sync`, - final: true, - ...(isFailure ? { cause: failure } : {}), - }), - }, - ) + expect( + collection.errors, + `${epoch}:${plan.ending} error`, + ).toMatchObject({ + hasErrors: true, + failureReason: null, + error: expect.objectContaining( + isFailure ? { cause: failure } : {}, + ), + }) } assertReports() if (plan.ending === `cleanup` || plan.ending === `parse-error`) @@ -1168,7 +1168,7 @@ it.each(heldPlans)( * Collection keeps its rows and stays ready, a later service change does not * reach it, and a later on-demand load does not wait for a new subscription. * The load lists the service's current rows. Because the sync adapter stops - * receiving changes, it reports a final health error, so an application can + * receiving changes, it reports the Collection's `error`, so an application can * see that the rows can grow stale; only a restart subscribes again. This * lane makes that limit executable; reconnecting is left to the TrailBase * provider, and the lane does not claim the limit is the right product @@ -1185,10 +1185,10 @@ it.each(heldPlans)( * * Checkpoints: after the stream ends, after the service change, and after the * later load. Each compares rows, status, and subscribe calls. After the end, - * Collection health holds one final health error, and nothing clears it. + * The Collection's `error` stays, and nothing clears it in this sync run. */ it.each([`close`, `read-error`] as const)( - `keeps rows, reports a final health error, and does not resubscribe after the stream ends: %s`, + `keeps rows, reports an error, and does not resubscribe after the stream ends: %s`, async (ending) => { const service = new Map(raceServiceRows.map((row) => [row.id, row])) let controller!: ReadableStreamDefaultController @@ -1227,12 +1227,13 @@ it.each([`close`, `read-error`] as const)( await turn() expect(collection.status, `${ending} status after the end`).toBe(`ready`) expect(rows(), `${ending} rows after the end`).toEqual(established) - const finalHealth = { - isError: true, - error: expect.objectContaining({ phase: `sync`, final: true }), + const stopped = { + hasErrors: true, + failureReason: null, + error: expect.anything(), } - expect(collection.health, `${ending} health after the end`).toEqual( - finalHealth, + expect(collection.errors, `${ending} error after the end`).toMatchObject( + stopped, ) service.set(1, { id: 1, data: `v2` }) @@ -1249,8 +1250,8 @@ it.each([`close`, `read-error`] as const)( expect(rows(), `${ending} a later load lists current rows`).toEqual( sortedRows(service.values()), ) - expect(collection.health, `${ending} health after the load`).toEqual( - finalHealth, + expect(collection.errors, `${ending} error after the load`).toMatchObject( + stopped, ) } finally { await collection.cleanup() diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index b41cec4926..29a1bf0183 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -268,15 +268,11 @@ describe(`TrailBase Integration`, () => { await collection.preload() controller.enqueue({ Insert: { id: 1, updated: 0, data: `invalid` } }) await new Promise((resolve) => setTimeout(resolve, 0)) - // After Collection readiness the stream failure is a final health - // error, not a log line; the rows and status stay. + // After Collection readiness the stream failure is the Collection's + // `error`, not a log line; the rows and status stay. expect(reported).not.toHaveBeenCalled() expect(collection.status).toBe(`ready`) - expect(collection.health.error).toMatchObject({ - phase: `sync`, - final: true, - cause: failure, - }) + expect(collection.errors.error).toMatchObject({ cause: failure }) expect(cancel).toHaveBeenCalledOnce() expect(stream.locked).toBe(false) await collection.cleanup() @@ -333,13 +329,11 @@ describe(`TrailBase Integration`, () => { expect(recordApi.subscribe).toHaveBeenCalledOnce() expect(recordApi.list).toHaveBeenCalledOnce() // The adapter does not resubscribe, so every end of the stream is a - // final health error; a stream failure is its cause. + // stop: the Collection's `error`; a stream failure is its cause. expect(reported).not.toHaveBeenCalled() - expect(collection.health.error).toMatchObject({ - phase: `sync`, - final: true, - ...(ending === `error` ? { cause: failure } : {}), - }) + expect(collection.errors.error).toMatchObject( + ending === `error` ? { cause: failure } : {}, + ) await collection.cleanup() await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 75d8990f2f..e3c613d548 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -61,8 +61,13 @@ export interface UseLiveInfiniteQueryReturn< isPersistedReady: ComputedRef persistedError: ComputedRef isIdle: ComputedRef - isError: ComputedRef + /** The first load failed: there is nothing to show. */ + isLoadingError: ComputedRef + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: ComputedRef isCleanedUp: ComputedRef + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: ComputedRef pages: ComputedRef[number]>>> pageParams: ComputedRef> fetchNextPage: () => Promise @@ -86,8 +91,13 @@ export interface UseLiveInfiniteQueryReturnWithCollection< isPersistedReady: ComputedRef persistedError: ComputedRef isIdle: ComputedRef - isError: ComputedRef + /** The first load failed: there is nothing to show. */ + isLoadingError: ComputedRef + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: ComputedRef isCleanedUp: ComputedRef + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: ComputedRef pages: ComputedRef>> pageParams: ComputedRef> fetchNextPage: () => Promise @@ -259,8 +269,10 @@ export function useLiveInfiniteQuery< isPersistedReady: computed(() => snapshot.value.isPersistedReady), persistedError: computed(() => snapshot.value.persistedError), isIdle: computed(() => snapshot.value.isIdle), - isError: computed(() => snapshot.value.isError), + isLoadingError: computed(() => snapshot.value.isLoadingError), + isCrashed: computed(() => snapshot.value.isCrashed), isCleanedUp: computed(() => snapshot.value.isCleanedUp), + isSyncError: computed(() => snapshot.value.isSyncError), pages: computed( () => snapshot.value.pages as Array[number]>>, diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index f0ec4a7779..9d3289af51 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -50,10 +50,10 @@ function snapshotRows(data: unknown): Array { * @property isLoading - True while initial query data is loading * @property isReady - True when query has received first data and is ready * @property isIdle - True when query hasn't started yet - * @property isError - True when query encountered an error + * @property isLoadingError - True when the first load failed * @property isCleanedUp - True when query has been cleaned up */ -/** Status, persistence, and health refs every `useLiveQuery` result has. */ +/** Status, persistence, and error refs every `useLiveQuery` result has. */ export interface UseLiveQueryFlags { status: ComputedRef isLoading: ComputedRef @@ -62,8 +62,13 @@ export interface UseLiveQueryFlags { isPersistedReady: ComputedRef persistedError: ComputedRef isIdle: ComputedRef - isError: ComputedRef + /** The first load failed: there is nothing to show. */ + isLoadingError: ComputedRef + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: ComputedRef isCleanedUp: ComputedRef + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: ComputedRef errors: ComputedRef } @@ -151,13 +156,13 @@ export interface UseLiveQueryReturnWithSingleResultCollection< * * @example * // Handle loading and error states in template - * const { data, isLoading, isError, status } = useLiveQuery((q) => + * const { data, isLoading, isLoadingError, status } = useLiveQuery((q) => * q.from({ todos: todoCollection }) * ) * * // In template: * //
    Loading...
    - * //
    Error: {{ status }}
    + * //
    Error: {{ status }}
    * //
      * //
    • {{ todo.text }}
    • * //
    @@ -198,13 +203,13 @@ export function useLiveQuery( * * @example * // Handle all states uniformly - * const { data, isLoading, isReady, isError } = useLiveQuery({ + * const { data, isLoading, isReady, isLoadingError } = useLiveQuery({ * query: (q) => q.from({ items: itemCollection }) * }) * * // In template: * //
    Loading...
    - * //
    Something went wrong
    + * //
    Something went wrong
    * //
    Preparing...
    * //
    {{ data.length }} items loaded
    */ @@ -244,11 +249,11 @@ export function useLiveQuery( * * @example * // Handle states consistently - * const { data, isLoading, isError } = useLiveQuery(sharedQuery) + * const { data, isLoading, isLoadingError } = useLiveQuery(sharedQuery) * * // In template: * //
    Loading...
    - * //
    Error loading data
    + * //
    Error loading data
    * //
    * // * //
    @@ -459,8 +464,12 @@ export function useLiveQuery( isPersistedReady: computed(() => persistedStatus.value === `ready`), persistedError: computed(() => persistedError.value), isIdle: computed(() => status.value === `idle`), - isError: computed(() => status.value === `error`), + isLoadingError: computed(() => status.value === `error`), + isCrashed: computed(() => status.value === `crashed`), isCleanedUp: computed(() => status.value === `cleaned-up`), + isSyncError: computed( + () => status.value === `ready` && errors.value?.hasErrors === true, + ), errors: computed(() => errors.value), } } diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index a003703b45..679283a151 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -27,7 +27,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' -import { makeHealthSource } from '../../db/tests/conformance/health-source' +import { makeSyncSource } from '../../db/tests/conformance/sync-source' +import { makeErrorSource as makeSharedErrorSource } from '../../db/tests/conformance/error-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' import type { @@ -102,27 +103,12 @@ function makePrecreated(build: QueryBuild, opts?: { startSync?: boolean }) { return { collection } } -function makeErrorSource() { - const expectedError = new Error(`conformance: sync failure`) - let startup: { returned: true } | { returned: false; error: unknown } = { - returned: true, - } - const collection = createCollection<{ id: string }>({ - id: `conformance-vue-err-${sourceSeq++}`, - getKey: (r) => r.id, - startSync: false, - sync: { - sync: () => { - throw expectedError - }, - }, - }) - try { - collection.startSyncImmediate() - } catch (error) { - startup = { returned: false, error } - } - return { collection, expectedError, startup } +function makeErrorSource(kind: `fail` | `crash`) { + return makeSharedErrorSource( + createCollection, + `conformance-vue-err-${sourceSeq++}`, + kind, + ) } async function settle() { @@ -141,7 +127,9 @@ function makeHandle(result: any, scope: ReturnType) { persistedStatus: result.persistedStatus?.value, isPersistedReady: result.isPersistedReady?.value, persistedError: result.persistedError?.value, - isError: result.isError?.value, + isLoadingError: result.isLoadingError?.value, + isCrashed: result.isCrashed?.value, + isSyncError: result.isSyncError?.value, // vue-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result.status?.value !== `disabled`, errors: result.errors?.value, @@ -229,8 +217,8 @@ const vueDriver: LiveQueryDriver = { createCollection, `conformance-vue-lagging-${sourceSeq++}`, ), - makeHealthSource: (data: ReadonlyArray) => - makeHealthSource( + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( createCollection, `conformance-vue-health-${sourceSeq++}`, data, @@ -295,7 +283,9 @@ it(`preserves raw result types through the actual driver reader`, () => { state: new Map(), status: `disabled`, isReady: false, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, } const scope = effectScope() @@ -315,9 +305,9 @@ it(`preserves raw result types through the actual driver reader`, () => { expect(healthy.data).toBe(raw.data) expect(healthy.state).toBe(raw.state) expect(healthy.isReady).toBe(false) - expect(healthy.isError).toBe(false) + expect(healthy.isLoadingError).toBe(false) expect(healthy.isEnabled).toBe(false) - for (const key of [`status`, `isReady`, `isError`]) { + for (const key of [`status`, `isReady`, `isLoadingError`]) { const original = raw[key] delete raw[key] expect(() => handle.current()).toThrowError(new RegExp(`raw ${key}`)) diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index f3caa8b850..7de1ce7b9c 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -130,7 +130,7 @@ function makeHandle( pageParams: result.pageParams.value, hasNextPage: result.hasNextPage.value, isFetchingNextPage: result.isFetchingNextPage.value, - isError: result.isError.value, + isLoadingError: result.isLoadingError.value, status: result.status.value, collection: result.collection.value, } From 075c65785500eaa5fe9eb3e0adb49e0cfc76bab1 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 16 Oct 2026 12:56:20 -0700 Subject: [PATCH 059/106] feat(db)!: refuse cleanup under active dependents; add collection.reload() Stages 2 and 3 of the error-surface redesign. - collection.subscribers lists live queries, Effects, and direct subscriptions (kind, collection, active, end()). - cleanup() rejects with CollectionHasSubscribersError (249) while an active live query or an Effect reads the Collection; cleanup({ dependents: true }) ends them first. Direct subscriptions keep their restart contract. Pooled partitions count as one live-query dependent and end cleaned-up; cleaning up a pooled view releases an idle partition. - collection.reload() starts a new sync run like a sync-side truncate: rows, status, and the current error stay, the node shows `reloading`, and the first commit replaces the rows and clears the problem (error becomes ready). A failed reload shows its new error. Reload returns the in-flight promise, rejects on crashed (250), and works during loading and after error. A live query's reload reloads its failed sources; a query whose sources recover starts again, shown loading. - Primary oracle gains a reload step and histories; cleanup-admission oracle gains a refusal/cascade witness; pooled oracle models dependent cleanup. Laws 14/16, glossary, and error docs updated. Co-authored-by: Isaac --- docs/contributing/glossary.md | 2 +- docs/errors.md | 30 ++++ packages/db/mangle-cache.json | 11 +- packages/db/src/client.ts | 6 +- packages/db/src/collection/error-tree.ts | 2 + packages/db/src/collection/index.ts | 70 +++++++- packages/db/src/collection/lifecycle.ts | 131 ++++++++++++-- packages/db/src/collection/subscription.ts | 13 ++ packages/db/src/collection/sync.ts | 162 ++++++++++++++--- packages/db/src/errors.ts | 30 ++++ packages/db/src/live-query-observer.ts | 2 +- packages/db/src/query/effect.ts | 27 ++- packages/db/src/query/live/ARCHITECTURE.md | 25 ++- .../src/query/live/bucket-facade-adapter.ts | 3 +- .../query/live/collection-config-builder.ts | 50 ++++++ .../src/query/live/collection-subscriber.ts | 7 + packages/db/src/query/pooled-live-query.ts | 85 ++++++--- packages/db/src/types.ts | 32 ++++ .../collection-cleanup-restart-oracle.test.ts | 132 ++++++++++++-- .../collection-subscribe-changes.test.ts | 6 +- .../tests/conformance-disabled-laws.test.ts | 1 + .../db-client-preload-identity-oracle.test.ts | 12 +- packages/db/tests/db-client.test.ts | 2 +- packages/db/tests/effect.test.ts | 26 +-- packages/db/tests/error-sample-arguments.ts | 3 + packages/db/tests/fixtures/error-codes.json | 3 + .../db/tests/fixtures/error-messages.json | 11 +- ...-query-deferred-acquisition-oracle.test.ts | 7 +- .../live-query-window-controller.test.ts | 2 +- .../tests/query/live-query-collection.test.ts | 4 +- ...ad-subset-replay-refinement-oracle.test.ts | 9 +- .../query/pooled-live-query-gc-oracle.test.ts | 11 +- .../pooled-live-query-oracle.property.test.ts | 30 ++-- .../db/tests/source-health-oracle.test.ts | 169 +++++++++++++----- packages/db/tests/sync-reused-row.test.ts | 10 +- .../tests/load-hooks.test.ts | 5 +- .../tests/on-demand-sync.test.ts | 3 +- .../tests/powersync.test.ts | 2 +- ...infinite-query-render-cuts-oracle.test.tsx | 7 +- .../tests/source-id-reuse-oracle.test.tsx | 2 +- 40 files changed, 953 insertions(+), 192 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 19fae66b6e..71dd93d789 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -37,7 +37,7 @@ production queues, caches, or semantic helpers merely to share their names. | provider | The external database, service, or SDK from which a sync adapter acquires data. | Sync adapter or source Collection. | | sync run | One invocation of a Collection's sync function, plus the callbacks and resources installed by that invocation, until cleanup invalidates them. A run may own zero or more provider requests and may outlive any one request. Component-owned `syncRunGeneration` counters fence this lifetime in Collection state, sync ownership, and live-query graph work. | Provider session, replay, generation, or request. | | provider session | A provider-defined remote stream, connection, or SDK lifetime. Always qualify it with the provider. | Sync run. | -| cleanup | The transition that ends the current sync run and releases its resources. Callback invalidation and local teardown begin synchronously. The public cleanup promise and final `cleaned-up` status settle after adapter cleanup settles. The Collection object remains available for cleanup or restart. | Collection destruction, restart, or replay. | +| cleanup | The transition that ends the current sync run and releases its resources. Public `cleanup()` rejects while a live query or Effect reads the Collection; `cleanup({ dependents: true })` ends those dependents first. A direct subscription survives cleanup. Callback invalidation and local teardown begin synchronously. The public cleanup promise and final `cleaned-up` status settle after adapter cleanup settles. The Collection object remains available for cleanup or restart. | Collection destruction, restart, or replay. | | cleanup start | The internal boundary at which cleanup synchronously closes restart admission, invalidates the current sync run, crashes dependent live queries, and marks dependent Effects disposed. The public Collection status keeps its prior value while adapter cleanup is pending. | A Collection status, cleanup settlement, or proof that adapter resources or Effect handlers settled. | | restart | Starting a new sync run after the prior sync run has ended. | Same-run recovery or replay. | | truncate replay | An authoritative source replacement after a truncate, inside the current sync run. The Collection subscription remains active; any dependent live-query graph remains active behind its publication barrier. | Restart, reload, retry, or repair. | diff --git a/docs/errors.md b/docs/errors.md index e3bf81a504..1f02084d86 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -107,6 +107,36 @@ Development builds show the full message. For example: Cannot perform operation on collection "collection-1" - collection is in error state. Try calling cleanup() and restarting the collection. ``` + + +## Error 249: `CollectionHasSubscribersError` + +Development builds show the full message. For example: + +```text +Cannot clean up collection "collection-1" while 2 live query or Effect dependent(s) read it. Clean them up first, or call cleanup({ dependents: true }). collection.subscribers lists them. +``` + + + +## Error 250: `CollectionReloadCrashedError` + +Development builds show the full message. For example: + +```text +Cannot reload collection "collection-1" because it crashed. A crash is permanent for the Collection instance; clean it up and create a new one. +``` + + + +## Error 251: `CollectionReloadDuringSyncEntryError` + +Development builds show the full message. For example: + +```text +Cannot reload collection "collection-1" from inside its sync function. Reload after the sync function returns. +``` + ## Error 9: `InvalidCollectionStatusTransitionError` diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index ef310a27be..5cf1d83793 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -420,5 +420,14 @@ "enterErrorState": "gM", "crashQuery": "gN", "failQuery": "gW", - "assertReady": "gX" + "assertReady": "gX", + "reloadTruncatePending": "gY", + "reloadRun": "gZ", + "adapterStopped": "g_", + "recovering": "g$", + "abortLoadSubsetWork": "g0", + "markReloadTruncate": "g1", + "commitEmptyReloadTruncate": "g2", + "dependentEntry": "g3", + "enterSync": "g4" } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 87c62f5c3e..4e794d5427 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -422,7 +422,7 @@ export class DbClient { const preloaded = this.preloadedLiveQueries.get(queryHash) if (!preloaded) return preloaded.observer.dispose() - void preloaded.collection.cleanup().catch(() => {}) + void preloaded.collection.cleanup({ dependents: true }).catch(() => {}) this.preloadedLiveQueries.delete(queryHash) } @@ -815,13 +815,13 @@ export class DbClient { )), ...(await Promise.allSettled( Array.from(liveQueryCollections, (collection) => - collection.cleanup(), + collection.cleanup({ dependents: true }), ), )), ...(await Promise.allSettled( materializedCollections .filter((collection) => !liveQueryCollections.has(collection)) - .map((collection) => collection.cleanup()), + .map((collection) => collection.cleanup({ dependents: true })), )), ] const failure = cleanupResults.find( diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index 3c0ae33c02..4816622092 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -49,6 +49,7 @@ export function buildErrorTree( status: collection.status, failureReason, error, + reloading: collection._lifecycle.reloading, ...(instances ? { instances: countInstances(instances()) } : {}), sources: children, }) @@ -114,6 +115,7 @@ function sameNode(a: ErrorTreeNode, b: ErrorTreeNode): boolean { a.hasErrors === b.hasErrors && a.failureReason === b.failureReason && a.error === b.error && + a.reloading === b.reloading && a.instances?.total === b.instances?.total && a.instances?.withErrors === b.instances?.withErrors && a.sources.length === b.sources.length && diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index f503066fbc..65b24b4960 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -3,10 +3,12 @@ import { safeRandomUUID } from '../utils/uuid' import { collectionOptionsClaim } from '../collection-options.js' import { CollectionConfigurationError, + CollectionHasSubscribersError, CollectionRequiresConfigError, CollectionRequiresSyncConfigError, } from '../errors' import { codedMessage, devBuild } from '../error-message' +import { getBuilderFromConfig } from '../query/live/collection-registry.js' import { validateCollectionConfig } from './validate-config' import { currentStateAsChanges } from './change-events' @@ -31,6 +33,7 @@ import type { IndexOptions } from '../indexes/index-options.js' import type { ChangeMessage, CollectionConfig, + CollectionDependent, CollectionStatus, CurrentStateAsChangesOptions, ErrorTreeNode, @@ -1287,6 +1290,45 @@ export class CollectionImpl< return this._events.waitFor(event, timeout) } + /** + * Start a new sync run while everything visible stays: the rows, the + * status, and the current error, with `reloading` on this Collection's + * error tree node. The new run's first commit replaces the rows at once and + * clears the node's problem; a `status` of `error` becomes `ready`. If the + * new run fails first, the rows and status stay and the node shows the new + * error. On a live query, it reloads each of its sources that has an + * `error`, and then the query itself if its own first load failed. + * + * Returns the promise of a reload in flight when called again. Rejects on a + * `crashed` Collection: a crash is permanent for the instance. + */ + public reload(): Promise { + const builder = getBuilderFromConfig(this.config) + return builder ? builder.reload() : this._sync.reload() + } + + /** + * Live queries, Effects, and direct subscriptions that read this + * Collection. `cleanup()` refuses while a live query or Effect reads it. + */ + public get subscribers(): ReadonlyArray { + // A live query or Effect can hold several subscriptions, one per alias. + const dependents = new Set() + for (const subscription of this._changes.changeSubscriptions) { + dependents.add( + subscription.owner ?? { + kind: `subscription`, + active: true, + end: () => { + subscription.unsubscribe() + return Promise.resolve() + }, + }, + ) + } + return [...dependents] + } + /** * Clean up the collection by stopping sync and clearing data * This can be called manually or automatically by garbage collection @@ -1294,9 +1336,33 @@ export class CollectionImpl< * Wait until cleanup completes before starting a new sync run. If adapter * cleanup rejects, this promise rejects after the Collection reaches its * final cleaned-up state. + * + * Rejects with `CollectionHasSubscribersError` while an active live query + * (one that still has a subscriber) or an Effect reads this Collection, + * because its readers would lose their data. A live query nothing reads + * any more ends with this Collection's cleanup start. With + * `{ dependents: true }`, it first cleans up each live query (and its own + * dependents) and disposes each Effect. A direct subscription survives + * cleanup and asks for data again when a new sync run starts. */ - public cleanup(): Promise { - return this._lifecycle.cleanup() + public cleanup(options?: { dependents?: boolean }): Promise { + const dependents = this.subscribers.filter( + (dependent) => dependent.kind !== `subscription`, + ) + if (!options?.dependents) { + const active = dependents.filter((dependent) => dependent.active) + if (active.length > 0) { + return Promise.reject( + new CollectionHasSubscribersError(this.id, active.length), + ) + } + return this._lifecycle.cleanup() + } + // Each dependent's cleanup starts synchronously, before this Collection's + // own cleanup start, so none of them sees the source end under it. + const ended = dependents.map((dependent) => dependent.end()) + const cleanup = this._lifecycle.cleanup() + return Promise.all([...ended, cleanup]).then(() => undefined) } } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 5029fdd015..a81552c69a 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -1,5 +1,6 @@ import { CollectionInErrorStateError, + CollectionPreloadAbortedError, CollectionStateError, InvalidCollectionStatusTransitionError, } from '../errors' @@ -11,6 +12,7 @@ import { runAllCallbacks } from '../utils/callbacks' import { createDeferred } from '../deferred' import { codedMessage, devBuild } from '../error-message' import { CleanupQueue } from './cleanup-queue' +import type { Deferred } from '../deferred' import type { IdleCallbackDeadline } from '../utils/browser-polyfills' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { @@ -65,6 +67,13 @@ export class CollectionLifecycleManager< // object until the sync run ends. private statusError: CollectionErrorInfo | null = null private requestErrors = new RequestErrors() + // fail() ends the sync adapter's reporting for its sync run; a reload or + // cleanup starts a new one. + private adapterStopped = false + // A reload's new sync run, until its first commit or its failure. + private reloadRun: Deferred | undefined + // A reload is moving the Collection out of `error`. + private recovering = false private cleanupConfig: () => void private statusRevision = 0 private cleaningUp = false @@ -118,14 +127,22 @@ export class CollectionLifecycleManager< // After the first ready only a crash changes the status; a sync // problem is error tree detail. ready: [`cleaned-up`, `crashed`], - // A failed first load is final for its sync run. + // A failed first load is final for its sync run. Only a reload leaves + // it for `ready` or `loading` (see `recovering`). error: [`cleaned-up`, `idle`, `crashed`], // A crash is permanent for the Collection instance. crashed: [`cleaned-up`], 'cleaned-up': [`loading`, `error`, `crashed`], } - if (!validTransitions[from].includes(to)) { + if ( + !validTransitions[from].includes(to) && + !( + this.recovering && + from === `error` && + (to === `ready` || to === `loading`) + ) + ) { throw new InvalidCollectionStatusTransitionError(from, to, this.id) } } @@ -191,7 +208,16 @@ export class CollectionLifecycleManager< public markReady(beforeEffects?: () => void): void { // A crash ended the sync run from core's side; the adapter is not at fault. if (this.status === `crashed`) return - if (this.stoppedError) this.assertAdapterActive(`markReady`) + if (this.adapterStopped) { + // A failed first load is final for its sync run. + if (this.status === `error`) + throw new InvalidCollectionStatusTransitionError( + this.status, + `ready`, + this.id, + ) + this.assertAdapterActive(`markReady`) + } const failure = this.applyReadyTransition(beforeEffects) if (!failure) return if (this.readyFailureSink) this.readyFailureSink(failure) @@ -218,8 +244,9 @@ export class CollectionLifecycleManager< beforeEffects?: () => void, ): { error: unknown } | undefined { this.validateStatusTransition(this.status, `ready`) - // A successful initial sync establishes a ready snapshot. - if (this.status === `loading`) { + // A successful initial sync establishes a ready snapshot, as does a + // reload of a failed first load. + if (this.status === `loading` || this.status === `error`) { const readyRevision = this.statusRevision + 1 this.setStatus(`ready`, true, beforeEffects) @@ -265,15 +292,95 @@ export class CollectionLifecycleManager< public fail(error: unknown): void { if (this.status === `crashed`) return this.assertAdapterActive(`fail`) - if (this.status !== `ready`) { + this.adapterStopped = true + const reload = this.reloadRun + this.reloadRun = undefined + if (this.status === `ready`) { + // The rows stay; a reload that failed shows its new error instead. + this.failureReason = null + this.stoppedError = describeProblem(error) + this.events.emitErrorsChange() + } else if (reload && this.status === `error`) { + // A reload of a failed first load keeps status `error` with the new + // cause. + this.statusError = describeProblem(error) + this.events.emitErrorsChange() + } else { this.validateStatusTransition(this.status, `error`) this.statusError = describeProblem(error) this.setStatus(`error`) - return } - this.failureReason = null - this.stoppedError = describeProblem(error) + reload?.reject(error) + } + + /** Whether a reload's new sync run is in flight. */ + public get reloading(): boolean { + return this.reloadRun !== undefined + } + + /** + * A reload starts a new sync run while everything visible stays: the rows, + * the status, and the current problem. The node shows `reloading`. + */ + public beginReload(): Promise { + if (this.reloadRun) return this.reloadRun.promise + const run = createDeferred() + void run.promise.catch(() => undefined) + this.reloadRun = run + this.adapterStopped = false this.events.emitErrorsChange() + return run.promise + } + + /** + * The reload's first commit replaced the rows: its problems clear, and a + * failed first load becomes ready. + */ + public completeReload(): void { + const run = this.reloadRun + if (!run) return + this.reloadRun = undefined + this.failureReason = null + this.stoppedError = null + this.requestErrors = new RequestErrors() + if (this.status === `ready`) this.events.emitErrorsChange() + else { + // A failed first load, or a reload during the first load, becomes + // ready with the new run's rows. + this.statusError = null + this.recovering = true + try { + this.markReady() + } finally { + this.recovering = false + } + } + run.resolve() + } + + /** A live query whose sources recovered starts again, shown `loading`. */ + public restartFromError(): void { + this.statusError = null + this.recovering = true + try { + this.setStatus(`loading`) + } finally { + this.recovering = false + } + } + + /** A reload retired the sync run; subscriptions detach its demand. */ + public retireSubscriptionDemand(): void { + for (const subscription of this.changes.changeSubscriptions) { + subscription.retireSyncRun() + } + } + + /** The reload ended without a commit, for example by cleanup. */ + public abandonReload(error: unknown): void { + const run = this.reloadRun + this.reloadRun = undefined + run?.reject(error) } /** @@ -284,6 +391,8 @@ export class CollectionLifecycleManager< public crash(error?: unknown): void { if (this.status === `crashed`) return this.validateStatusTransition(this.status, `crashed`) + // A crash ends a reload in flight with it. + this.abandonReload(error) this.statusError = describeProblem(error) this.setStatus(`crashed`) } @@ -340,7 +449,7 @@ export class CollectionLifecycleManager< /** fail() ends the sync adapter's reporting for its sync run. */ private assertAdapterActive(operation: string): void { - if (this.status === `error` || this.stoppedError) { + if (this.adapterStopped) { throw new CollectionStateError( devBuild() && process.env.NODE_ENV !== `production` ? `${operation}() was called after fail() in the same sync run of Collection "${this.id}". fail() means the sync adapter stopped; a reload starts a new sync run.` @@ -597,6 +706,8 @@ export class CollectionLifecycleManager< this.hasBeenReady = false // Problems belong to the discarded sync run. + this.adapterStopped = false + this.abandonReload(new CollectionPreloadAbortedError()) this.failureReason = null this.stoppedError = null this.requestErrors = new RequestErrors() diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index a51d129a1b..3c57653d77 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -18,6 +18,7 @@ import type { BasicExpression, OrderBy } from '../query/ir.js' import type { IndexReader } from '../indexes/base-index.js' import type { ChangeMessage, + CollectionDependent, LoadSubsetOptions, LoadSubsetRequestResult, Subscription, @@ -87,6 +88,8 @@ type CollectionSubscriptionOptions = { deferAcquisition?: boolean /** Called once when deferred acquisition resumes. */ onResumeAcquisition?: () => void + /** The live query or Effect this subscription reads for. */ + owner?: CollectionDependent } type TruncateReplayPublicationControl = Readonly<{ @@ -206,6 +209,11 @@ export class CollectionSubscription private unsubscribed = false private defersAcquisition: boolean + /** @internal The live query or Effect this subscription reads for. */ + public get owner(): CollectionSubscriptionOptions[`owner`] { + return this.options.owner + } + public get status(): SubscriptionStatus { return this._status } @@ -304,6 +312,11 @@ export class CollectionSubscription } /** Detach logical demand from work owned by a discarded sync run. */ + /** @internal A reload retired the sync run; detach its demand. */ + public retireSyncRun(): void { + this.handleCollectionCleanup() + } + private handleCollectionCleanup(): void { this.discardTruncateReplay() this.stalePublishedRows = new Map(this.publishedRows) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 6f776421e4..3c626715e4 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -2,6 +2,8 @@ import { CollectionConfigurationError, CollectionIsInErrorStateError, CollectionPreloadAbortedError, + CollectionReloadCrashedError, + CollectionReloadDuringSyncEntryError, DuplicateKeySyncError, LoadSubsetOperationAbortedError, NoPendingSyncTransactionCommitError, @@ -22,7 +24,6 @@ import type { ChangeMessageOrDeleteKeyMessage, CleanupFn, CollectionConfig, - CollectionStatus, LoadSubsetFn, LoadSubsetOptions, LoadSubsetRequestResult, @@ -96,6 +97,8 @@ export class CollectionSyncManager< private syncCallbackEpoch = 0 private syncRunGeneration = 0 private syncEntryActive = false + // A reload's new sync run has not begun its first transaction yet. + private reloadTruncatePending = false private pendingSyncEntryCleanup: | { tasks: Array> @@ -196,9 +199,48 @@ export class CollectionSyncManager< } const syncCallbackEpoch = ++this.syncCallbackEpoch - const isCurrentSync = () => syncCallbackEpoch === this.syncCallbackEpoch this.lifecycle.setStatus(`loading`) - if (!isCurrentSync()) return + if (syncCallbackEpoch !== this.syncCallbackEpoch) return + this.enterSync(syncCallbackEpoch) + } + + /** + * Start a new sync run while everything visible stays, like a sync-side + * truncate: the run's first commit replaces every synced row at once, or + * its first `markReady()` replaces them with none. A reload in flight + * returns its promise; a crashed Collection cannot reload. + */ + public reload(options: { showLoading?: boolean } = {}): Promise { + const status = this.lifecycle.status + if (status === `crashed`) { + return Promise.reject(new CollectionReloadCrashedError(this.id)) + } + if (this.lifecycle.reloading) return this.lifecycle.beginReload() + if (status === `idle` || status === `cleaned-up`) return this.preload() + if (this.syncEntryActive) { + return Promise.reject(new CollectionReloadDuringSyncEntryError(this.id)) + } + const reload = this.lifecycle.beginReload() + const syncCallbackEpoch = ++this.syncCallbackEpoch + const retired = this.retireSyncRun() + void retired.catch((error: unknown) => this.lifecycle.abandonReload(error)) + // Subscriptions detach demand from the retired run; the reload's + // truncate replays it through the new run's loader. + this.lifecycle.retireSubscriptionDemand() + this.reloadTruncatePending = true + if (options.showLoading && status === `error`) { + this.lifecycle.restartFromError() + } + try { + this.enterSync(syncCallbackEpoch) + } catch (error) { + this.lifecycle.abandonReload(error) + } + return reload + } + + private enterSync(syncCallbackEpoch: number): void { + const isCurrentSync = () => syncCallbackEpoch === this.syncCallbackEpoch const finishReadyFailures = this.lifecycle.deferReadyFailures() let readyFailure: { error: unknown } | undefined @@ -225,6 +267,8 @@ export class CollectionSyncManager< applied, duplicateKeyError: (key) => this.createDuplicateKeyError(key), }) + if (this.reloadTruncatePending && this.lifecycle.reloading) + this.markReloadTruncate(applied) }, write: ( messageWithOptionalKey: ChangeMessageOrDeleteKeyMessage< @@ -317,9 +361,20 @@ export class CollectionSyncManager< return withAcceptedReceipt(pendingTransaction.applied.promise, true) }, markReady: () => { + if (!isCurrentSync()) return + if (this.lifecycle.reloading) { + // A reload that has not committed replaces the rows with none. + if (this.reloadTruncatePending) this.commitEmptyReloadTruncate() + // A reload of a ready Collection completes when its commit + // applies; one that starts from `loading` or `error` becomes + // ready here, as a first load does. + if (this.lifecycle.status !== `ready`) + this.lifecycle.completeReload() + return + } // Readiness counts accepted rows. Rows a persisting optimistic // transaction holds still publish with the drop of its state. - if (isCurrentSync()) this.lifecycle.markReady() + this.lifecycle.markReady() }, fail: (error: unknown) => { if (isCurrentSync()) this.lifecycle.fail(error) @@ -399,7 +454,7 @@ export class CollectionSyncManager< // A throwing sync function is a programming error, even after ready. // A first load that already failed in this run reported its error. // The sync function can change the status before it throws. - const status = this.lifecycle.status as CollectionStatus + const status = this.lifecycle.status if (isCurrentSync() && status !== `error`) this.lifecycle.crash(error) throw error } @@ -466,6 +521,45 @@ export class CollectionSyncManager< } } + /** The reload's first transaction replaces every synced row. */ + private markReloadTruncate(applied: { promise: Promise }): void { + this.reloadTruncatePending = false + const transaction = this.state.pendingSyncedTransactions.at(-1)! + transaction.truncate = true + transaction.truncateMarkReady = false + this.state.refreshPendingSyncedProjection() + const syncRunGeneration = this.syncRunGeneration + void applied.promise.then( + () => { + if ( + syncRunGeneration === this.syncRunGeneration && + this.lifecycle.status === `ready` + ) + this.lifecycle.completeReload() + }, + (error: unknown) => this.lifecycle.abandonReload(error), + ) + } + + private commitEmptyReloadTruncate(): void { + const applied = createDeferred() + void applied.promise.catch(() => undefined) + this.state.pendingSyncedTransactions.push({ + committed: false, + applicationStarted: false, + layoutChanged: false, + operations: [], + rowMetadataWrites: new Map(), + explicitRowMetadataWrites: new Map(), + collectionMetadataWrites: new Map(), + applied, + duplicateKeyError: (key) => this.createDuplicateKeyError(key), + }) + this.markReloadTruncate(applied) + this.state.pendingSyncedTransactions.at(-1)!.committed = true + this.state.commitPendingTransactions() + } + private getActivePendingSyncTransaction() { const pendingTransaction = this.state.pendingSyncedTransactions.at(-1) @@ -995,32 +1089,26 @@ export class CollectionSyncManager< }) } - public cleanup(): true | Promise - public cleanup(onSettled: (failure?: { error: unknown }) => void): boolean - public cleanup( - onSettled?: (failure?: { error: unknown }) => void, - ): boolean | Promise { - // Invalidate callbacks retained by asynchronous work from this sync run - // before invoking adapter cleanup or allowing a new sync run to start. - ++this.syncCallbackEpoch + /** + * Retire the current adapter run for a reload: its callbacks, loaders, and + * resources, but not the visible state, the status, or a waiting preload. + */ + private retireSyncRun(): Promise { this.syncRunGeneration++ - this.rejectPreload?.(new CollectionPreloadAbortedError()) const cleanup = this.syncCleanupFn this.syncCleanupFn = null this.syncLoadSubsetFn = null this.syncUnloadSubsetFn = null - - let cleanupResult: true | Promise = true - let cleanupFailure: { error: unknown } | undefined + this.abortLoadSubsetWork() try { - cleanupResult = this.invokeCleanup(cleanup) + const result = this.invokeCleanup(cleanup) + return result === true ? Promise.resolve() : result } catch (error) { - cleanupFailure = { error } + return Promise.reject(error) } + } - this.preloadPromise = null - this.syncStartDeferred = false - this.syncStartRequested = false + private abortLoadSubsetWork(): void { const wasLoadingSubset = this.pendingLoadSubsetPromises.size > 0 this.pendingLoadSubsetPromises.clear() if (wasLoadingSubset) { @@ -1042,6 +1130,36 @@ export class CollectionSyncManager< for (const request of deferredLoadSubsets) { request.deferred.reject(new LoadSubsetOperationAbortedError()) } + } + + public cleanup(): true | Promise + public cleanup(onSettled: (failure?: { error: unknown }) => void): boolean + public cleanup( + onSettled?: (failure?: { error: unknown }) => void, + ): boolean | Promise { + // Invalidate callbacks retained by asynchronous work from this sync run + // before invoking adapter cleanup or allowing a new sync run to start. + ++this.syncCallbackEpoch + this.syncRunGeneration++ + this.reloadTruncatePending = false + this.rejectPreload?.(new CollectionPreloadAbortedError()) + const cleanup = this.syncCleanupFn + this.syncCleanupFn = null + this.syncLoadSubsetFn = null + this.syncUnloadSubsetFn = null + + let cleanupResult: true | Promise = true + let cleanupFailure: { error: unknown } | undefined + try { + cleanupResult = this.invokeCleanup(cleanup) + } catch (error) { + cleanupFailure = { error } + } + + this.preloadPromise = null + this.syncStartDeferred = false + this.syncStartRequested = false + this.abortLoadSubsetWork() if (this.syncEntryActive) { const completion = onSettled ? undefined : createDeferred() diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index f45910b122..f63406d0a9 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -149,6 +149,36 @@ export class CollectionInErrorStateError extends CollectionStateError { } } +export class CollectionHasSubscribersError extends CollectionStateError { + constructor(collectionId: string, dependents: number) { + super( + devBuild() && process.env.NODE_ENV !== `production` + ? `Cannot clean up collection "${collectionId}" while ${dependents} live query or Effect dependent(s) read it. Clean them up first, or call cleanup({ dependents: true }). collection.subscribers lists them.` + : codedMessage(249, { collectionId, dependents }), + ) + } +} + +export class CollectionReloadCrashedError extends CollectionStateError { + constructor(collectionId: string) { + super( + devBuild() && process.env.NODE_ENV !== `production` + ? `Cannot reload collection "${collectionId}" because it crashed. A crash is permanent for the Collection instance; clean it up and create a new one.` + : codedMessage(250, { collectionId }), + ) + } +} + +export class CollectionReloadDuringSyncEntryError extends CollectionStateError { + constructor(collectionId: string) { + super( + devBuild() && process.env.NODE_ENV !== `production` + ? `Cannot reload collection "${collectionId}" from inside its sync function. Reload after the sync function returns.` + : codedMessage(251, { collectionId }), + ) + } +} + export class InvalidCollectionStatusTransitionError extends CollectionStateError { constructor(from: string, to: string, collectionId: string) { super( diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 97d8c0598e..66c4c10da4 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -914,7 +914,7 @@ class LiveQueryObserverImpl< async () => { const collection = this.collection this.dispose() - await collection?.cleanup() + await collection?.cleanup({ dependents: true }) }, ) } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index b60ca01743..9ee9802602 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -41,6 +41,7 @@ import type { } from './compiler/joins.js' import type { ChangeMessage, + CollectionDependent, ErrorTreeNode, KeyedStream, StringCollationConfig, @@ -311,8 +312,16 @@ export function createEffect< return attempt } + const effect: Effect = { + dispose, + get disposed() { + return disposed + }, + } + // Create and start the pipeline const runner = new EffectPipelineRunner({ + effect, query: config.query, skipInitial: config.skipInitial ?? false, onBatchProcessed, @@ -366,12 +375,7 @@ export function createEffect< throw error } - return { - dispose, - get disposed() { - return disposed - }, - } + return effect } // --------------------------------------------------------------------------- @@ -388,6 +392,8 @@ interface EffectPipelineRunnerConfig< /** Called when a source collection enters error or cleaned-up state */ onSourceError: (error: Error) => void onErrorsChange?: (errors: ErrorTreeNode) => void + /** The Effect's handle, so its source subscriptions can name it. */ + effect: Effect } /** @@ -478,6 +484,8 @@ class EffectPipelineRunner { private readonly requests = new RequestErrors() private reportedTree: ErrorTreeNode | undefined private readonly onErrorsChange: ((errors: ErrorTreeNode) => void) | undefined + // This Effect as its sources' `subscribers` list it. + private readonly dependent: CollectionDependent // Scheduler integration private subscribedToAllCollections = false @@ -498,6 +506,11 @@ class EffectPipelineRunner { this.onBatchProcessed = config.onBatchProcessed this.onSourceError = config.onSourceError this.onErrorsChange = config.onErrorsChange + this.dependent = { + kind: `effect`, + active: true, + end: () => config.effect.dispose(), + } // Parse query this.query = buildQueryFromConfig({ query: config.query }) @@ -634,6 +647,7 @@ class EffectPipelineRunner { orderByInfo, whereExpression, ), + owner: this.dependent, onLoadSubsetError: ({ error }) => this.failLoad(sourceId, normalizeError(error)), // subscribeChanges can report a result before it returns the @@ -1106,6 +1120,7 @@ class EffectPipelineRunner { status: this.reachedReady ? `ready` : `loading`, failureReason: null, error: this.requests.problem, + reloading: false, sources: this.collectionSources.map(({ sourceId, alias, collection }) => buildErrorTree(collection, { alias, id: sourceId }, this), ), diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 4d6401660a..0d97689dfc 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -777,6 +777,14 @@ source rows belong in a query result. ### Cleanup, restart, and detached waiters +Public cleanup first checks admission. `cleanup()` rejects with +`CollectionHasSubscribersError` while a live query or Effect reads the +Collection; `cleanup({ dependents: true })` first cleans up each such live query, +with its own dependents, and disposes each such Effect. A pooled live query +counts as one live-query dependent per equality partition. A direct +subscription is not refused. `collection.subscribers` lists all three kinds. +Internal teardown, such as a retiring Collection facade, bypasses admission. + Collection cleanup has two boundaries. Cleanup start is an internal synchronous boundary. It closes restart admission, invalidates the current sync run, detaches its demand, crashes dependent live queries, and marks dependent Effects @@ -818,7 +826,8 @@ fresh private publication barrier. Settlements from the old sync run cannot publish rows, report errors, or change readiness in the new sync run. This is the direct subscription's restart contract, not automatic recovery of -a dependent live query. Manually cleaning up a source crashes its live +a dependent live query. Public cleanup refuses while a live query reads the +source; internal teardown that bypasses admission crashes its live queries. Restarting that source alone does not revive their graphs or publish replacement results; callers must restart or recreate the live query itself. This differs from a source truncate, which keeps the live @@ -1503,8 +1512,11 @@ the compiler's comparator and break ties by key (`packages/db/src/query/pooled-live-query.ts`). It builds its live-query Collection only when the application reads it. -A pooled live query must publish what its live-query Collection would: the same rows, in its order or else key order, with the same values and status. When its source -starts cleanup, it crashes the same way and keeps its last rows. +A pooled live query must publish what its live-query Collection would: the same rows, in its order or else key order, with the same values and status. Its +partition is one live-query dependent of the source: cleanup with dependents +ends it like a live-query Collection, `cleaned-up` with no rows, and cleaning +up a view releases an idle partition at once. Internal teardown that starts +source cleanup under it crashes it the same way and keeps its last rows. The pooled live query oracle compares the two over generated histories. ### Physical planning and work @@ -1589,8 +1601,11 @@ every row before the top-K (a recorded limit). materialization cells, visible rows, the current private replay state, and required Collection facades—not with settled historical replay attempts or raw delta history. -14. **Cleanup:** cleanup start synchronously closes restart admission, - crashes dependent live queries, and marks dependent Effects disposed. The source Collection keeps its prior status until adapter cleanup +14. **Cleanup:** public cleanup rejects while a live query or Effect reads the + Collection, and with `{ dependents: true }` it ends those dependents + first; a direct subscription survives cleanup. Cleanup start + synchronously closes restart admission, crashes any dependent live query + left by internal teardown, and marks dependent Effects disposed. The source Collection keeps its prior status until adapter cleanup settles; only then does it publish `cleaned-up` and settle its cleanup promise. 15. **Deferred acquisition:** a live-query Collection or pooled live query diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index e060adc431..15980ef04d 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -480,7 +480,8 @@ export class BucketFacadeAdapter { withError = next this.onInstanceErrorsChange() }) - const dispose = collection.cleanup.bind(collection) + // The adapter retires its facades itself, even while readers subscribe. + const dispose = () => collection._lifecycle.cleanup() // Other parent rows can share this facade, so a holder must not end it. collection.cleanup = () => Promise.reject( diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index f82d84f042..f6a1ef230b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -39,6 +39,7 @@ import type { Collection } from '../../collection/index.js' import type { ErrorTreeSource } from '../../collection/error-tree.js' import type { CollectionConfigSingleRowOption, + CollectionDependent, KeyedStream, ResultStream, StringCollationConfig, @@ -127,6 +128,38 @@ export class CollectionConfigBuilder< // Reference to the live query collection for error state transitions public liveQueryCollection?: Collection + private dependentEntry: CollectionDependent | undefined + + /** + * Reload each source with an error, then this query if its own first load + * failed. A source's reload keeps this query's status until it commits. + */ + async reload(): Promise { + const failed = this.collectionSources + .map(({ collection }) => collection) + .filter( + (collection, index, all) => + all.indexOf(collection) === index && + (collection.status === `error` || collection.errors.error !== null), + ) + await Promise.all(failed.map((collection) => collection.reload())) + const query = this.liveQueryCollection + if (query?.status === `error`) await query._sync.reload() + } + + /** This live query as its sources' `subscribers` list it. */ + dependent(): CollectionDependent | undefined { + const collection = this.liveQueryCollection + if (!collection) return undefined + return (this.dependentEntry ??= { + kind: `live-query`, + collection, + get active() { + return collection.subscribers.length > 0 + }, + end: () => collection.cleanup({ dependents: true }), + }) + } /** * Whether this live-query Collection had a subscriber or a preload in its @@ -1221,6 +1254,23 @@ export class CollectionConfigBuilder< return } + // A query whose first load failed because a source failed starts a new + // sync run once no source has a failure left, for example after the + // source reloaded. It shows `loading` until that run is ready. + if ( + status === `ready` && + this.liveQueryCollection?.status === `error` && + this.collectionSources.every( + ({ collection }) => + collection.status !== `error` && collection.status !== `crashed`, + ) + ) { + void this.liveQueryCollection._sync + .reload({ showLoading: true }) + .catch(() => undefined) + return + } + if (status === `ready`) { if ( Object.values(this.optimizableOrderByCollections).some( diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 26d0796604..b7f45612d2 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -51,6 +51,11 @@ export class CollectionSubscriber< private collectionConfigBuilder: CollectionConfigBuilder, ) {} + /** The live query this subscription reads for, as `subscribers` lists it. */ + private owner(): SubscribeChangesOptions[`owner`] { + return this.collectionConfigBuilder.dependent() + } + subscribe(): CollectionSubscription { const whereClause = this.collectionConfigBuilder.sourceWhereClausesCache?.get(this.sourceId) @@ -290,6 +295,7 @@ export class CollectionSubscriber< onStatusChange, onLoadSubsetError, deferAcquisition: this.defersAcquisition(), + owner: this.owner(), truncateReplayPublication: this.truncateReplayPublicationControl(), orderBy: hints.orderBy, limit: hints.limit, @@ -330,6 +336,7 @@ export class CollectionSubscriber< onStatusChange, onLoadSubsetError, deferAcquisition: this.defersAcquisition(), + owner: this.owner(), truncateReplayPublication: this.truncateReplayPublicationControl(() => { // Recovery favors a simple, authoritative rebuild over resuming a // fragile cursor. The retained full-source demand is replayed on later diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 6aa65209cd..9dd070a9b4 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -20,6 +20,7 @@ import type { Collection, CollectionImpl } from '../collection/index.js' import type { CollectionSubscription } from '../collection/subscription.js' import type { ChangeMessage, + CollectionDependent, CollectionErrorInfo, CollectionStatus, ErrorTreeNode, @@ -81,8 +82,25 @@ class Partition { readonly statusListeners = new Set() private listenerCount = 0 private gcTime = 0 - /** Set when the source starts cleanup; groups keep their last rows. */ - terminated = false + /** + * Set when the partition ends for good: `crashed` when its source starts + * cleanup under it (groups keep their last rows), `cleaned-up` when the + * source's cleanup ends its dependents first (groups empty, as a cleaned-up + * live-query Collection's rows do). + */ + terminated: `crashed` | `cleaned-up` | undefined = undefined + // This partition as its source's `subscribers` list it: one pooled live + // query for every view it serves. + private readonly dependent: CollectionDependent = ((partition) => ({ + kind: `live-query` as const, + get active(): boolean { + return partition.listenerCount > 0 + }, + end: () => { + partition.end() + return Promise.resolve() + }, + }))(this) constructor( private readonly source: CollectionImpl, @@ -167,7 +185,7 @@ class Partition { this.subscription = this.source.subscribeChanges( (changes) => this.apply(changes as Array>), - { includeInitialState: true, deferAcquisition }, + { includeInitialState: true, deferAcquisition, owner: this.dependent }, ) const stopStatus = this.source.on(`status:change`, (event) => { this.deliverStatus(event) @@ -175,16 +193,9 @@ class Partition { // Like a live query, a pooled view crashes when its source starts // cleanup, before the adapter's cleanup settles; queries mounted later // get a new partition. - const stopCleanupStart = this.source._onCleanupStart(() => { - const previousStatus = this.source.status - this.terminate() - this.deliverStatus({ - type: `status:change`, - collection: this.source as unknown as Collection, - previousStatus, - status: `crashed`, - }) - }) + const stopCleanupStart = this.source._onCleanupStart(() => + this.end(`crashed`), + ) this.stopStatusEvents = () => { stopStatus() stopCleanupStart() @@ -192,9 +203,27 @@ class Partition { } } + /** End the partition for good and tell its views. */ + private end(status: `crashed` | `cleaned-up` = `cleaned-up`): void { + if (this.terminated) return + const previousStatus = this.source.status + this.terminated = status + CleanupQueue.getInstance().cancel(this) + this.release() + if (status === `cleaned-up`) { + for (const group of this.groups.values()) group.rows.clear() + } + this.deliverStatus({ + type: `status:change`, + collection: this.source as unknown as Collection, + previousStatus, + status, + }) + } + private deliverStatus(event: CollectionStatusChangeEvent): void { const delivered = this.terminated - ? { ...event, status: `crashed` as const } + ? { ...event, status: this.terminated } : event for (const listener of [...this.statusListeners]) listener(delivered) if (this.terminated) { @@ -216,12 +245,6 @@ class Partition { this.scheduleRelease(0) } - private terminate(): void { - this.terminated = true - CleanupQueue.getInstance().cancel(this) - this.release() - } - private release(): void { this.subscription?.unsubscribe() this.subscription = undefined @@ -236,6 +259,17 @@ class Partition { CleanupQueue.getInstance().schedule(this, delay, this.releaseIfUnused) } + /** + * Release the source subscription now if no view listens, as cleaning up a + * live-query Collection releases its source subscriptions at once. A later + * mount subscribes again. + */ + releaseIfIdle(): void { + CleanupQueue.getInstance().cancel(this) + this.releaseIfUnused() + this.scheduleRelease(0) + } + private readonly releaseIfUnused = (): void => { if (this.listenerCount > 0) return this.stopStatusEvents?.() @@ -510,7 +544,7 @@ class PooledLiveQuery { } get status(): CollectionStatus { - return this.partition.terminated ? `crashed` : this.source.status + return this.partition.terminated ?? this.source.status } private endedProblem: CollectionErrorInfo | undefined @@ -522,7 +556,7 @@ class PooledLiveQuery { // ended this view, for example when its source was cleaned up under it. const error = this.source._lifecycle.statusProblem ?? - (this.partition.terminated + (this.partition.terminated === `crashed` ? (this.endedProblem ??= describeProblem( new Error( devBuild() && process.env.NODE_ENV !== `production` @@ -538,6 +572,7 @@ class PooledLiveQuery { status: this.status, failureReason: null, error, + reloading: false, sources: [source], }) } @@ -640,8 +675,10 @@ class PooledLiveQuery { return this.source.preload() } - cleanup(): Promise { - return this.collection?.cleanup() ?? Promise.resolve() + cleanup(options?: { dependents?: boolean }): Promise { + const cleanup = this.collection?.cleanup(options) ?? Promise.resolve() + this.partition.releaseIfIdle() + return cleanup } private proxy: Collection | undefined = undefined diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index ba95c1cab4..5ee94244cc 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -792,6 +792,11 @@ export interface ErrorTreeNode { readonly hasErrors: boolean readonly failureReason: CollectionErrorInfo | null readonly error: CollectionErrorInfo | null + /** + * A reload's new sync run is in flight: the rows, status, and problem shown + * are the previous run's until it commits or fails. + */ + readonly reloading: boolean /** For an include node: its per-row child Collections, and how many have a problem. */ readonly instances?: { readonly total: number; readonly withErrors: number } readonly sources: ReadonlyArray @@ -1151,6 +1156,27 @@ export type NamespacedAndKeyedStream = IStreamBuilder /** * Options for subscribing to collection changes */ +/** + * Something that reads a Collection, as listed by `collection.subscribers`: + * a live query, an Effect, or a direct subscription. + */ +export interface CollectionDependent { + readonly kind: `live-query` | `effect` | `subscription` + /** A live query's Collection; a pooled live query has none. */ + readonly collection?: Collection + /** + * Whether something still reads through this dependent: an Effect or a + * subscription always does; a live query does while it has a subscriber. + * `cleanup()` refuses only while an active live query or Effect remains. + */ + readonly active: boolean + /** + * End this dependent: clean up the live query with its own dependents, + * dispose the Effect, or end the subscription. + */ + end: () => Promise +} + export interface SubscribeChangesOptions< T extends object = Record, TKey extends string | number = string | number, @@ -1183,6 +1209,12 @@ export interface SubscribeChangesOptions< * @internal */ deferAcquisition?: boolean + /** + * The live query or Effect this subscription reads for, so + * `collection.subscribers` can name it. + * @internal + */ + owner?: CollectionDependent /** * Listener for subscription status changes. * Registered BEFORE any snapshot is requested, ensuring no status transitions are missed. diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index b297bad148..c9848c71bf 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -14,8 +14,14 @@ import type { SyncConfig } from '../src/types' * # When does cleanup become observable? * * Authority comes from the public `Collection.cleanup()` contract and the - * cleanup and cleanup-start terms in the contributing glossary. Cleanup has - * two boundaries. Cleanup start synchronously closes restart admission and + * cleanup and cleanup-start terms in the contributing glossary. Public cleanup + * first checks admission: it rejects while a live query or Effect reads the + * Collection, and `cleanup({ dependents: true })` first cleans up each such + * live query, with its own dependents, and disposes each such Effect. A direct + * subscription is not refused: it survives cleanup and asks for data again in + * the next sync run. `collection.subscribers` lists all three kinds. The + * boundary histories below drive internal teardown, which bypasses that check, + * as a retiring facade does. Cleanup has two boundaries. Cleanup start synchronously closes restart admission and * puts dependent live queries in terminal error while marking dependent Effects * disposed. Cleanup settlement publishes `cleaned-up` and settles the public * cleanup promise only after adapter cleanup settles. Cleanup start is internal; @@ -215,6 +221,97 @@ describe(`Collection cleanup oracle calibration`, () => { }) describe(`Collection cleanup admission oracle`, () => { + it(`refuses cleanup while a live query or Effect reads it, and ends dependents first on request`, async () => { + let starts = 0 + let ops!: Parameters[`sync`]>[0] + const source = createCollection({ + getKey: (row) => row.id, + sync: { + sync: (params) => { + ops = params + starts++ + params.begin() + params.write({ type: `insert`, value: { id: starts, rank: starts } }) + params.commit() + params.markReady() + }, + }, + }) + const inner = createLiveQueryCollection((q) => q.from({ row: source })) + const outer = createLiveQueryCollection((q) => q.from({ row: inner })) + const effect = createEffect({ + query: (q) => q.from({ row: source }), + onBatch: () => {}, + }) + const received: Array = [] + const direct = source.subscribeChanges((changes) => { + for (const change of changes) received.push(change.key) + }) + try { + await outer.preload() + // Model: three readers of the source, one of each kind; the outer query + // reads the inner one, not the source. + expect(source.subscribers.map(({ kind }) => kind).sort()).toEqual([ + `effect`, + `live-query`, + `subscription`, + ]) + expect( + source.subscribers.find(({ kind }) => kind === `live-query`) + ?.collection, + ).toBe(inner) + expect(inner.subscribers.map(({ collection }) => collection)).toEqual([ + outer, + ]) + + // A plain cleanup refuses and changes nothing. + await expect(source.cleanup()).rejects.toThrow(/dependent/) + expect([source.status, inner.status, outer.status]).toEqual([ + `ready`, + `ready`, + `ready`, + ]) + expect(effect.disposed).toBe(false) + + // With its dependents, cleanup ends the query chain and the Effect + // first. The direct subscription survives into the next sync run. + await source.cleanup({ dependents: true }) + expect([source.status, inner.status, outer.status]).toEqual([ + `cleaned-up`, + `cleaned-up`, + `cleaned-up`, + ]) + expect(effect.disposed).toBe(true) + expect(source.subscribers.map(({ kind }) => kind)).toEqual([ + `subscription`, + ]) + received.length = 0 + source.startSyncImmediate() + expect(starts).toBe(2) + ops.begin() + ops.write({ type: `insert`, value: { id: 9, rank: 9 } }) + ops.commit() + expect(received).toContain(9) + // Only direct subscriptions and an idle live query remain. Nothing + // reads the idle query, so a plain cleanup is admitted; the idle query + // ends at cleanup start, as a query under internal teardown does. + const idle = createLiveQueryCollection((q) => q.from({ row: source })) + await idle.preload() + expect( + source.subscribers.find(({ collection }) => collection === idle) + ?.active, + ).toBe(false) + await source.cleanup() + expect(source.status).toBe(`cleaned-up`) + expect(idle.status).toBe(`crashed`) + } finally { + direct.unsubscribe() + await outer.cleanup({ dependents: true }) + await effect.dispose() + await source.cleanup({ dependents: true }) + } + }) + it(`calibrates every declared bounded-product cell exactly once`, () => { expectExactCaseIds(reentryScenarios, [ `abort:plain:1`, @@ -471,8 +568,10 @@ describe(`Collection cleanup admission oracle`, () => { try { await loadEntered.promise + // Internal teardown that bypasses cleanup admission, such as a retiring + // facade, still crashes the dependent at cleanup start. let cleanupSettled = false - const cleanup = source.cleanup().then(() => { + const cleanup = source._lifecycle.cleanup().then(() => { cleanupSettled = true }) @@ -502,7 +601,7 @@ describe(`Collection cleanup admission oracle`, () => { } }) - it(`puts a live query in terminal error once when one source has two aliases`, async () => { + it(`crashes a live query once when internal teardown retires a source it reads under two aliases`, async () => { const cleanupGate = createDeferred() const source = createCollection({ getKey: (row) => row.id, @@ -531,7 +630,8 @@ describe(`Collection cleanup admission oracle`, () => { try { await live.preload() - const cleanup = source.cleanup() + // Internal teardown that bypasses admission crashes the query once. + const cleanup = source._lifecycle.cleanup() expect(live.status).toBe(`crashed`) expect(reports).toHaveBeenCalledTimes(1) @@ -583,8 +683,12 @@ describe(`Collection cleanup admission oracle`, () => { expect(effect.disposed).toBe(false) expect(source.subscriberCount).toBe(1) + // The public cleanup refuses while the Effect reads the source. + // Internal teardown that bypasses it disposes the Effect at cleanup + // start. + await expect(source.cleanup()).rejects.toThrow(/dependent/) let cleanupSettled = false - const cleanup = source.cleanup().then(() => { + const cleanup = source._lifecycle.cleanup().then(() => { cleanupSettled = true }) const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) @@ -988,7 +1092,7 @@ describe(`Collection cleanup admission oracle`, () => { } }) - it(`does not revive a terminal live query when its source restarts`, async () => { + it(`does not revive a cleaned-up live query when its source restarts`, async () => { let starts = 0 const source = createCollection({ getKey: (row) => row.id, @@ -1014,16 +1118,20 @@ describe(`Collection cleanup admission oracle`, () => { expect(live.get(1)?.rank).toBe(1) publications.length = 0 - await source.cleanup() - expect(live.status).toBe(`crashed`) + // Cleanup refuses while the live query reads the source; with its + // dependents it cleans the live query up first. + await expect(source.cleanup()).rejects.toThrow(/dependent/) + await source.cleanup({ dependents: true }) + expect(live.status).toBe(`cleaned-up`) + // Restarting the source does not revive the cleaned-up live query, + // and its surviving subscriber receives nothing from the new run. source.startSyncImmediate() expect(source.status).toBe(`ready`) expect(starts).toBe(2) - expect(live.status).toBe(`crashed`) - expect(live.get(1)?.rank).toBe(1) + expect(live.status).toBe(`cleaned-up`) expect(publications).toEqual([]) - expect(reports).toHaveBeenCalledTimes(1) + expect(reports).not.toHaveBeenCalled() } finally { subscription.unsubscribe() reports.mockRestore() diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index e1bfe342b6..9710b27e2f 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -2915,7 +2915,7 @@ describe(`Virtual properties`, () => { expect(liveRow.$collectionId).toBe(`virtual-props-source`) liveSub.unsubscribe() - await source.cleanup() + await source.cleanup({ dependents: true }) await live.cleanup() }) @@ -2966,7 +2966,7 @@ describe(`Virtual properties`, () => { ).toBe(false) liveSub.unsubscribe() - await source.cleanup() + await source.cleanup({ dependents: true }) await syncedOnly.cleanup() }) @@ -3038,7 +3038,7 @@ describe(`Virtual properties`, () => { expect(groupRow!.$collectionId).toBe(`virtual-props-aggregate-source`) persistInsert() - await source.cleanup() + await source.cleanup({ dependents: true }) await grouped.cleanup() }) diff --git a/packages/db/tests/conformance-disabled-laws.test.ts b/packages/db/tests/conformance-disabled-laws.test.ts index d1920ce6c8..b5747652ac 100644 --- a/packages/db/tests/conformance-disabled-laws.test.ts +++ b/packages/db/tests/conformance-disabled-laws.test.ts @@ -62,6 +62,7 @@ const faults: Array<[string, (value: ConformanceResult) => void]> = [ hasErrors: false, failureReason: null, error: null, + reloading: false, sources: [], } }, diff --git a/packages/db/tests/db-client-preload-identity-oracle.test.ts b/packages/db/tests/db-client-preload-identity-oracle.test.ts index 355ef41eef..83c1583fd8 100644 --- a/packages/db/tests/db-client-preload-identity-oracle.test.ts +++ b/packages/db/tests/db-client-preload-identity-oracle.test.ts @@ -478,8 +478,8 @@ describe(`DbClient preload source identity`, () => { observer?.dispose() await view?.cleanup() await client.cleanup() - await first.cleanup() - await second.cleanup() + await first.cleanup({ dependents: true }) + await second.cleanup({ dependents: true }) } }, ) @@ -555,8 +555,8 @@ describe(`DbClient preload source identity`, () => { await nextView?.cleanup() await firstView?.cleanup() await client.cleanup() - await first.cleanup() - await second.cleanup() + await first.cleanup({ dependents: true }) + await second.cleanup({ dependents: true }) } }, ) @@ -604,8 +604,8 @@ describe(`DbClient preload source identity`, () => { } finally { observer?.dispose() await client.cleanup() - await first.cleanup() - await second.cleanup() + await first.cleanup({ dependents: true }) + await second.cleanup({ dependents: true }) } }, ) diff --git a/packages/db/tests/db-client.test.ts b/packages/db/tests/db-client.test.ts index c3a103af6f..27b5605ddc 100644 --- a/packages/db/tests/db-client.test.ts +++ b/packages/db/tests/db-client.test.ts @@ -889,7 +889,7 @@ describe(`DbClient`, () => { await client.preloadLiveQuery(options) const source = client.collection(descriptor) - await source.cleanup() + await source.cleanup({ dependents: true }) await client.preloadLiveQuery(options) await expect( diff --git a/packages/db/tests/effect.test.ts b/packages/db/tests/effect.test.ts index c769789a47..041b8b6f64 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -2312,7 +2312,7 @@ describe(`createEffect`, () => { } }) - it(`should auto-dispose when source collection is cleaned up`, async () => { + it(`refuses source cleanup while it reads, and disposes with its source's dependents`, async () => { const users = createUsersCollection() const events: Array> = [] const consoleErrorSpy = vi @@ -2330,19 +2330,21 @@ describe(`createEffect`, () => { expect(events.length).toBe(3) expect(effect.disposed).toBe(false) - // Clean up the source collection — should auto-dispose the effect - await users.cleanup() + // The source refuses cleanup while the Effect reads it; with its + // dependents it disposes the Effect first, which is not a source error. + await expect(users.cleanup()).rejects.toThrow(/dependent/) + expect(effect.disposed).toBe(false) + expect(users.subscribers.map(({ kind }) => kind)).toEqual([`effect`]) + await users.cleanup({ dependents: true }) await flushPromises() expect(effect.disposed).toBe(true) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`cleaned up`), - ) + expect(consoleErrorSpy).not.toHaveBeenCalled() consoleErrorSpy.mockRestore() }) - it(`should not fire events after source collection is cleaned up`, async () => { + it(`should not fire events after its source is cleaned up with its dependents`, async () => { const users = createUsersCollection() const events: Array> = [] vi.spyOn(console, `error`).mockImplementation(() => {}) @@ -2355,8 +2357,8 @@ describe(`createEffect`, () => { await flushPromises() const countAfterInit = events.length - // Clean up source — effect auto-disposes - await users.cleanup() + // Clean up source with its dependents — the effect disposes first + await users.cleanup({ dependents: true }) await flushPromises() expect(effect.disposed).toBe(true) @@ -2384,7 +2386,9 @@ describe(`createEffect`, () => { await flushPromises() - await users.cleanup() + // Internal teardown that bypasses the public refusal ends the Effect + // with a source error. + await users._lifecycle.cleanup() await flushPromises() expect(effect.disposed).toBe(true) @@ -2412,7 +2416,7 @@ describe(`createEffect`, () => { await flushPromises() - await users.cleanup() + await users._lifecycle.cleanup() await flushPromises() expect(effect.disposed).toBe(true) diff --git a/packages/db/tests/error-sample-arguments.ts b/packages/db/tests/error-sample-arguments.ts index 0f658be69b..210db5e291 100644 --- a/packages/db/tests/error-sample-arguments.ts +++ b/packages/db/tests/error-sample-arguments.ts @@ -27,6 +27,9 @@ export const errorSampleArguments: Record>> = { // Collection state errors CollectionStateError: [['State error']], CollectionInErrorStateError: [['operation', 'collection-1']], + CollectionHasSubscribersError: [['collection-1', 2]], + CollectionReloadCrashedError: [['collection-1']], + CollectionReloadDuringSyncEntryError: [['collection-1']], InvalidCollectionStatusTransitionError: [ ['pending', 'ready', 'collection-1'], ], diff --git a/packages/db/tests/fixtures/error-codes.json b/packages/db/tests/fixtures/error-codes.json index c2f38a1bc9..f1747cc278 100644 --- a/packages/db/tests/fixtures/error-codes.json +++ b/packages/db/tests/fixtures/error-codes.json @@ -6,6 +6,9 @@ "InvalidSchemaError": 6, "SchemaMustBeSynchronousError": 7, "CollectionInErrorStateError": 8, + "CollectionHasSubscribersError": 249, + "CollectionReloadCrashedError": 250, + "CollectionReloadDuringSyncEntryError": 251, "InvalidCollectionStatusTransitionError": 9, "CollectionIsInErrorStateError": 10, "NegativeActiveSubscribersError": 11, diff --git a/packages/db/tests/fixtures/error-messages.json b/packages/db/tests/fixtures/error-messages.json index 68daf0eb50..4f59ff5c54 100644 --- a/packages/db/tests/fixtures/error-messages.json +++ b/packages/db/tests/fixtures/error-messages.json @@ -19,6 +19,15 @@ "CollectionInErrorStateError": [ "Cannot perform operation on collection \"collection-1\" - collection is in error state. Try calling cleanup() and restarting the collection." ], + "CollectionHasSubscribersError": [ + "Cannot clean up collection \"collection-1\" while 2 live query or Effect dependent(s) read it. Clean them up first, or call cleanup({ dependents: true }). collection.subscribers lists them." + ], + "CollectionReloadCrashedError": [ + "Cannot reload collection \"collection-1\" because it crashed. A crash is permanent for the Collection instance; clean it up and create a new one." + ], + "CollectionReloadDuringSyncEntryError": [ + "Cannot reload collection \"collection-1\" from inside its sync function. Reload after the sync function returns." + ], "InvalidCollectionStatusTransitionError": [ "Invalid collection status transition from \"pending\" to \"ready\" for collection \"collection-1\"" ], @@ -137,7 +146,7 @@ ], "QueryMustHaveFromClauseError": ["Query must have a from clause"], "InvalidWhereExpressionError": [ - "Invalid where() expression: Expected a query expression, but received a boolean. This usually happens when using JavaScript's comparison operators (===, !==, <, >, etc.) directly. Instead, use the query builder functions:\n\n ❌ .where(({ user }) => user.id === 'abc')\n ✅ .where(({ user }) => eq(user.id, 'abc'))\n\nAvailable comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, isNull, isUndefined" + "Invalid where() expression: Expected a query expression, but received a boolean. This usually happens when using JavaScript's comparison operators (===, !==, <, >, etc.) directly. Instead, use the query builder functions:\n\n \u274c .where(({ user }) => user.id === 'abc')\n \u2705 .where(({ user }) => eq(user.id, 'abc'))\n\nAvailable comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, isNull, isUndefined" ], "QueryCompilationError": ["Compilation error"], "UnsafeAliasPathError": [ diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index 80194dc0af..1c5a7c5ec0 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -569,7 +569,7 @@ describe(`pooled live-query deferred acquisition`, () => { } finally { observer?.dispose() await view?.cleanup() - await source.cleanup() + await source.cleanup({ dependents: true }) await client?.cleanup() } }, @@ -633,7 +633,7 @@ describe(`pooled live-query deferred acquisition`, () => { } finally { subscription?.unsubscribe() await view?.cleanup() - await source.cleanup() + await source.cleanup({ dependents: true }) } }) } @@ -1097,7 +1097,8 @@ describe(`a subscriber that outlives cleanup`, () => { try { outer.startSyncImmediate() await settle() - await inner.cleanup() + // Drive internal teardown, which bypasses cleanup admission. + await inner._lifecycle.cleanup() inner.startSyncImmediate() await settle() expect(counts.starts, `source sync-run starts`).toBe(0) diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 97dcb45135..234a220138 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -1524,6 +1524,6 @@ describe(`createLiveQueryWindowController`, () => { unsubscribe() controller.dispose() - await source.cleanup() + await source.cleanup({ dependents: true }) }) }) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 85111e08f2..16cc25d0de 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1000,8 +1000,8 @@ describe(`createLiveQueryCollection`, () => { expect(activeUsers.size).toBe(2) // Tear down the source collection and the live query, e.g. when switching - // to a different data set at runtime. Cleaning up a source collection - // crashes the dependent live query. + // to a different data set at runtime. Nothing subscribes to the live + // query, so the source's cleanup is admitted and ends the query. await usersCollection.cleanup() expect(activeUsers.status).toBe(`crashed`) diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index c2605fb6b8..283a449b66 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -195,8 +195,13 @@ describe(`loadSubset replay refinement`, () => { expect(readEvents()).toEqual(initial) publications.length = 0 - await source.cleanup() - if (live) expect(live.status).toBe(`crashed`) + // A live query refuses the public cleanup; internal teardown that + // bypasses it is fatal for the query. + if (live) { + await expect(source.cleanup()).rejects.toThrow(/dependent/) + await source._lifecycle.cleanup() + expect(live.status).toBe(`crashed`) + } else await source.cleanup() source.startSyncImmediate() await flushPromises() expect(loads).toBe(2) diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index e319b31ffb..d7a288b6cf 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -203,7 +203,7 @@ describe(`pooled live query gcTime`, () => { third.stop() }) - it(`turns terminal when source cleanup starts, before it settles`, async () => { + it(`ends with its source's dependents, before the source's cleanup settles`, async () => { let release!: () => void const source = createCollection({ id: `pooled-gc-${serial++}`, @@ -234,10 +234,13 @@ describe(`pooled live query gcTime`, () => { createLiveQueryCollection({ query: query(source), startSync: true }), ) await vi.advanceTimersByTimeAsync(1) - const cleanup = source.cleanup() + // Cleanup refuses while they read the source; with its dependents it + // ends both views like their live queries, before the adapter settles. + await expect(source.cleanup()).rejects.toThrow(/dependent/) + const cleanup = source.cleanup({ dependents: true }) await vi.advanceTimersByTimeAsync(1) - expect(compiledView.observer.getSnapshot().status).toBe(`crashed`) - expect(pooledView.observer.getSnapshot().status).toBe(`crashed`) + expect(compiledView.observer.getSnapshot().status).toBe(`cleaned-up`) + expect(pooledView.observer.getSnapshot().status).toBe(`cleaned-up`) release() await cleanup pooledView.stop() diff --git a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts index 694f1ab8a1..f44353b38f 100644 --- a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts +++ b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts @@ -47,15 +47,16 @@ * the last checkpoint equal those of a granular observer of the live-query * Collection, by type, key, value, and previous value; folded in order, they * never insert a held key, update or delete an unheld one, or carry a stale - * previous value, and they leave the model's rows. A peer mounted when its - * source starts cleanup is terminal like its live query: status `error` with - * the rows it had, pending optimistic rows included, through the restart and - * later writes. A peer mounted after cleanup follows the restarted source. + * previous value, and they leave the model's rows. Cleanup refuses while a + * live query reads the source, so the driver cleans up with its dependents: + * a peer mounted then ends like its live query, `cleaned-up` with no rows, + * through the restart and later writes. A peer mounted after cleanup follows + * the restarted source. * * Calibration: a partition that ignored the previous value, kept group rows * in arrival order, or compared literals without normalization fails the * pinned histories and campaigns. A view that kept reporting the source's - * status after cleanup fails the cleanup histories. An update delivered as a + * status, or kept its rows, after cleanup fails the cleanup histories. An update delivered as a * delete and insert, with a stale previous value, or with the previous row as * its value fails the in-group update history and both campaigns; so do a * partition born terminal on a cleaned-up source and a freeze that drops @@ -380,7 +381,7 @@ const pinnedHistories: ReadonlyArray<{ name: string; history: History }> = [ }, }, { - name: `a peer mounted at cleanup stays failed while a new one follows the restart`, + name: `a peer mounted at cleanup stays cleaned up while a new one follows the restart`, history: { rows: [{ f: `a`, g: `x` }], peers: [{ f: `a` }, { f: `a` }], @@ -600,7 +601,9 @@ async function runHistory(history: History): Promise { const references: Array> = [] type Mounted = { reference: ReturnType - // The model rows when the source started cleanup, if it has since. + // The model rows when the source's cleanup ended this peer, if it has. + // The peer then shows no rows; its granular stream got no deletes, so it + // still folds to these rows. frozen: Array | undefined view: { collection?: unknown @@ -686,7 +689,7 @@ async function runHistory(history: History): Promise { expect( [...snapshot.state!.values()].map(describeFields).sort(), `${label} model`, - ).toEqual(model) + ).toEqual(entry.frozen ? [] : model) // The granular stream delivers each change once, with the type and // values the live-query Collection's stream has, and folds to the model. expect(entry.violations, `${label} granular contradictions`).toEqual([]) @@ -746,8 +749,9 @@ async function runHistory(history: History): Promise { mounted.get(index)?.unsubscribe() mounted.delete(index) } - // Every mounted peer freezes with the rows it had, pending writes included. - // `beforeRestart` runs once the source is cleaned up. + // Cleanup ends every mounted peer first, as it does its live query: the + // peer is cleaned up with no rows from then on. `beforeRestart` runs once + // the source is cleaned up. const cleanupAndRestart = async ( checkpoint: string, beforeRestart: () => Promise, @@ -755,7 +759,7 @@ async function runHistory(history: History): Promise { for (const [index, entry] of mounted) { entry.frozen ??= expectedRows(rows, history.peers[index]!) } - await source.cleanup() + await source.cleanup({ dependents: true }) check(`${checkpoint} cleaned up`) // The mock source re-syncs its initial rows when it restarts. rows.clear() @@ -891,7 +895,7 @@ async function runHistory(history: History): Promise { for (const entry of mounted.values()) entry.unsubscribe() }, () => Promise.all(references.map((reference) => reference.cleanup())), - () => source.cleanup(), + () => source.cleanup({ dependents: true }), ]) } @@ -931,7 +935,7 @@ describe(`pooled live query oracle`, () => { expect(snapshot.status).toBe(reference.status) expect([...snapshot.state!.keys()]).toEqual([...reference.keys()]) stop() - await source.cleanup() + await source.cleanup({ dependents: true }) }) for (const { name, history } of pinnedHistories) { diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index ce4ff2f10f..4eea1d7864 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -61,7 +61,13 @@ * Steps act on Collection `A` or `B`: `ready` writes the Collection's row and calls * `markReady()`; `fail` calls `fail()`; `report` calls `reportError()`; * `healthy` calls `reportHealthy()`; `crash` crashes the Collection through - * its lifecycle, as a crash cause would. `restart` cleans up the + * its lifecycle, as a crash cause would; `reload` calls `collection.reload()`. + * A reload keeps the rows, status, and shown problem, and marks the node + * `reloading`, until the new run's first commit (the next `ready`) replaces + * the rows and clears the node, or its `fail` shows the new error. A + * reload of a crashed Collection rejects. A query whose first load failed + * starts again once no source has a failure, `loading` with `reloading` + * until it is ready. `restart` cleans up the * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw * for `markReady()` after `fail()`, for any report after `fail()`, and for a @@ -126,7 +132,7 @@ type Shape = `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` type Step = | { - kind: `ready` | `fail` | `report` | `healthy` | `crash` + kind: `ready` | `fail` | `report` | `healthy` | `crash` | `reload` source: SourceName } | { kind: `restart` } @@ -138,14 +144,15 @@ type Step = type Gate = `loading` | `ready` | `error` | `crashed` // `serial` names one reported problem. A new report is a new problem even // when it has the same field, so the node that shows it is a new node. -type Problem = null | { - field: `failureReason` | `error` - message: string - serial: number -} +type Problem = null | { message: string; serial: number } // `cause` is the message of what put the gate in `error` or `crashed`. -type GateModel = { gate: Gate; cause: string | null } -type SourceModel = GateModel & { problem: Problem } +// `reloading`: a reload's new sync run (or a query's restart) has not +// committed yet, so everything visible is the previous run's. +type GateModel = { gate: Gate; cause: string | null; reloading: boolean } +// The node shows `stopped` (from fail() after readiness) over `failure` +// (from reportError()); a reload's new run can report while the previous +// run's error still shows. +type SourceModel = GateModel & { failure: Problem; stopped: Problem } type Model = { sources: Record query: GateModel @@ -168,35 +175,43 @@ function freshModel(): Model { const source = (): SourceModel => ({ gate: `loading`, cause: null, - problem: null, + reloading: false, + failure: null, + stopped: null, }) return { sources: { A: source(), B: source() }, - query: { gate: `loading`, cause: null }, - inner: { gate: `loading`, cause: null }, + query: { gate: `loading`, cause: null, reloading: false }, + inner: { gate: `loading`, cause: null, reloading: false }, serial: 0, parentRows: 0, } } // A live query's status follows its sources until it is ready, and never -// leaves `error` or `crashed` within a sync run. A crashed input crashes the -// query at any time, even after its first load failed, as a crash does to a -// Collection; after the query is ready, nothing else changes it. The query -// takes the cause of the input that ended its sync run. +// leaves `crashed`. A crashed input crashes the query at any time, even after +// its first load failed, as a crash does to a Collection; after the query is +// ready, nothing else changes it. A failed first load stays until no input +// has a failure left (inputs leave `error` only by reloading): then the query +// starts a new sync run, `loading` with `reloading` until it is ready. The +// query takes the cause of the input that ended its sync run. function deriveGate( current: GateModel, inputs: ReadonlyArray, ): GateModel { if (current.gate === `crashed`) return current const crashed = inputs.find(({ gate }) => gate === `crashed`) - if (crashed) return { gate: `crashed`, cause: crashed.cause } - if (current.gate === `error`) return current + if (crashed) + return { gate: `crashed`, cause: crashed.cause, reloading: false } if (current.gate === `ready`) return current const failed = inputs.find(({ gate }) => gate === `error`) - if (failed) return { gate: `error`, cause: failed.cause } + if (current.gate === `error`) { + if (failed) return current + current = { gate: `loading`, cause: null, reloading: true } + } + if (failed) return { gate: `error`, cause: failed.cause, reloading: false } if (inputs.every(({ gate }) => gate === `ready`)) - return { gate: `ready`, cause: null } + return { gate: `ready`, cause: null, reloading: false } return current } @@ -211,8 +226,9 @@ function deriveQueries(shape: Shape, model: Model): void { model.inner = deriveGate(model.inner, gates([`A`])) model.query = model.inner } else { - // A running sync run receives `B`'s row once `B` syncs it; a failed - // sync run receives nothing more. + model.query = deriveGate(model.query, gates(shapeSources[shape])) + // A running sync run receives `B`'s row once `B` syncs it, including a + // run that started again; a failed sync run receives nothing more. if ( shape === `include` && (model.query.gate === `loading` || model.query.gate === `ready`) && @@ -220,7 +236,6 @@ function deriveQueries(shape: Shape, model: Model): void { ) { model.parentRows = 1 } - model.query = deriveGate(model.query, gates(shapeSources[shape])) } } @@ -241,18 +256,31 @@ function applyModel( if (!shapeSources[shape].includes(step.source)) return `ok` const source = model.sources[step.source] let outcome: `ok` | `throws` | `bad-transition` = `ok` - // After a crash, adapter calls do nothing. After fail(), they throw. - const stopped = source.gate === `error` || source.problem?.field === `error` + // After a crash, adapter calls do nothing and a reload rejects. After + // fail(), adapter calls throw until a reload starts a new sync run. + const stopped = + !source.reloading && (source.gate === `error` || source.stopped !== null) if (step.kind !== `crash` && source.gate === `crashed`) { deriveQueries(shape, model) - return outcome + return step.kind === `reload` ? `throws` : outcome } switch (step.kind) { case `ready`: - if (source.gate === `error`) outcome = `bad-transition` + if (source.reloading) { + // The new run's first commit replaces the rows and clears the node. + source.reloading = false + source.gate = `ready` + source.cause = null + source.failure = null + source.stopped = null + } else if (source.gate === `error`) outcome = `bad-transition` else if (stopped) outcome = `throws` else source.gate = `ready` break + case `reload`: + // Everything visible stays until the new run commits or fails. + source.reloading = true + break case `fail`: // Before Collection readiness the first load fails; after it, the // rows stay and the node shows the error. @@ -260,32 +288,36 @@ function applyModel( else if (source.gate !== `ready`) { source.gate = `error` source.cause = `${step.source} gave up` - } else - source.problem = { - field: `error`, + source.reloading = false + } else { + source.reloading = false + source.failure = null + source.stopped = { message: `${step.source} gave up`, serial: ++model.serial, } + } break case `report`: if (stopped || source.gate !== `ready`) outcome = `throws` else - source.problem = { - field: `failureReason`, + source.failure = { message: `${step.source} problem`, serial: ++model.serial, } break case `healthy`: if (stopped || source.gate !== `ready`) outcome = `throws` - else source.problem = null + else source.failure = null break case `crash`: // From any active status, for good. if (source.gate !== `crashed`) { source.gate = `crashed` source.cause = `${step.source} crashed` - source.problem = null + source.failure = null + source.stopped = null + source.reloading = false } break } @@ -303,6 +335,7 @@ type ExpectedNode = { hasErrors: boolean failureReason: ExpectedProblem error: ExpectedProblem + reloading: boolean instances?: { total: number; withErrors: number } sources: Array } @@ -315,18 +348,18 @@ function sourceNode( identity: boolean, kind: ExpectedNode[`kind`] = `source`, ): ExpectedNode { - const reported = (field: `failureReason` | `error`): ExpectedProblem => - source.problem?.field === field + const reported = (problem: Problem): ExpectedProblem => + problem ? { - message: source.problem.message, - ...(identity ? { serial: source.problem.serial } : {}), + message: problem.message, + ...(identity ? { serial: problem.serial } : {}), } : null const error = source.gate === `error` || source.gate === `crashed` ? { message: source.cause! } - : reported(`error`) - const failureReason = error ? null : reported(`failureReason`) + : reported(source.stopped) + const failureReason = error ? null : reported(source.failure) return { alias, kind, @@ -334,13 +367,14 @@ function sourceNode( hasErrors: error !== null || failureReason !== null, failureReason, error, + reloading: source.reloading, sources: [], } } function queryNode( alias: string, - { gate, cause }: GateModel, + { gate, cause, reloading }: GateModel, sources: Array, ): ExpectedNode { const error = @@ -352,6 +386,7 @@ function queryNode( hasErrors: error !== null || sources.some((node) => node.hasErrors), failureReason: null, error, + reloading, sources, } } @@ -529,6 +564,7 @@ function project(node: ErrorTreeNode): ExpectedNode { hasErrors: node.hasErrors, failureReason: problem(node.failureReason), error: problem(node.error), + reloading: node.reloading, ...(node.instances ? { instances: node.instances } : {}), sources: node.sources.map(project), } @@ -625,7 +661,7 @@ async function runHistory( try { if (step.kind === `restart`) { await query.cleanup() - await inner?.cleanup() + await inner?.cleanup({ dependents: true }) await Promise.all([ sources.A.collection.cleanup(), sources.B.collection.cleanup(), @@ -633,8 +669,19 @@ async function runHistory( begin() } else if (shapeSources[shape].includes(step.source)) { const params = sources[step.source].params - if (step.kind === `ready`) { - if (params.collection.status === `loading`) { + if (step.kind === `reload`) { + // A reload settles only when its new run commits or fails; a + // crashed Collection rejects at once. + const reload = params.collection.reload() + if (params.collection.status === `crashed`) + thrown = await reload.catch((e: unknown) => e) + else void reload.catch(() => undefined) + } else if (step.kind === `ready`) { + // A reload's new run commits its row, as a first load does. + if ( + params.collection.status === `loading` || + params.collection._lifecycle.reloading + ) { params.begin() params.write({ type: `insert`, @@ -717,7 +764,7 @@ async function runHistory( console.error = consoleError stop() await query.cleanup() - await inner?.cleanup() + await inner?.cleanup({ dependents: true }) await Promise.all([ sources.A.collection.cleanup(), sources.B.collection.cleanup(), @@ -787,6 +834,37 @@ const namedHistories: Record> = { on(`ready`, `A`), on(`ready`, `B`), ], + 'a reload keeps everything visible until its new run commits': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + on(`fail`, `A`), + on(`reload`, `A`), + on(`report`, `A`), + on(`healthy`, `A`), + on(`ready`, `A`), + ], + 'a reload recovers a failed first load, and the query starts again': [ + on(`fail`, `A`), + on(`reload`, `A`), + on(`reload`, `A`), + on(`ready`, `B`), + on(`ready`, `A`), + ], + 'a reload whose new run fails shows the new error': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`fail`, `A`), + on(`reload`, `A`), + on(`fail`, `A`), + on(`fail`, `B`), + on(`reload`, `A`), + ], + 'a crashed Collection cannot reload': [ + on(`ready`, `A`), + on(`crash`, `A`), + on(`reload`, `A`), + ], 'a report before Collection readiness throws': [ on(`report`, `A`), on(`healthy`, `B`), @@ -813,6 +891,7 @@ describe(`source failure and health oracle`, () => { `report` as const, `healthy` as const, `crash` as const, + `reload` as const, ), fc.constantFrom(`A` as const, `B` as const), ) diff --git a/packages/db/tests/sync-reused-row.test.ts b/packages/db/tests/sync-reused-row.test.ts index 63747e2b1b..62cac230c6 100644 --- a/packages/db/tests/sync-reused-row.test.ts +++ b/packages/db/tests/sync-reused-row.test.ts @@ -53,7 +53,7 @@ describe(`sync writes of a reused row object`, () => { expect(() => sync().write({ type: `update`, value: row })).toThrow( SyncRowReusedWithoutPreviousValueError, ) - await collection.cleanup() + await collection.cleanup({ dependents: true }) }) it(`accepts an in-place update that names its previous value`, async () => { @@ -67,7 +67,7 @@ describe(`sync writes of a reused row object`, () => { sync().write({ type: `update`, value: row, previousValue }) sync().commit() expect([...groupA.keys()]).toEqual([]) - await collection.cleanup() + await collection.cleanup({ dependents: true }) }) it(`accepts an unchanged rewrite after a declared in-place update`, async () => { @@ -83,7 +83,7 @@ describe(`sync writes of a reused row object`, () => { sync().begin() expect(() => sync().write({ type: `update`, value: row })).not.toThrow() sync().commit() - await collection.cleanup() + await collection.cleanup({ dependents: true }) }) it(`accepts an update with a new object`, async () => { @@ -94,7 +94,7 @@ describe(`sync writes of a reused row object`, () => { sync().write({ type: `update`, value: { id: `r`, group: `b` } }) sync().commit() expect([...groupA.keys()]).toEqual([]) - await collection.cleanup() + await collection.cleanup({ dependents: true }) }) it(`does not check in production`, async () => { @@ -105,6 +105,6 @@ describe(`sync writes of a reused row object`, () => { sync().begin() expect(() => sync().write({ type: `update`, value: row })).not.toThrow() sync().commit() - await collection.cleanup() + await collection.cleanup({ dependents: true }) }) }) diff --git a/packages/powersync-db-collection/tests/load-hooks.test.ts b/packages/powersync-db-collection/tests/load-hooks.test.ts index 4bfe417520..ccdd4ee52f 100644 --- a/packages/powersync-db-collection/tests/load-hooks.test.ts +++ b/packages/powersync-db-collection/tests/load-hooks.test.ts @@ -1564,7 +1564,10 @@ describe(`Sync Streams`, () => { expect(createDiffTrigger).not.toHaveBeenCalled() expect(query.status).toBe(`loading`) let cleanupSettled = false - const cleanupOutcome = collection.cleanup().then( + // An active query refuses the public cleanup; internal teardown that + // bypasses admission still settles the subset hook. + await expect(collection.cleanup()).rejects.toThrow(/dependent/) + const cleanupOutcome = collection._lifecycle.cleanup().then( () => { cleanupSettled = true return { status: `fulfilled` as const } diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index 5d470260cb..f9c546d274 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -574,7 +574,8 @@ describe(`On-Demand Sync Mode`, () => { expect(publications).toEqual([]) if (outcome === `cleanup`) { - await collection.cleanup() + // Internal teardown that bypasses cleanup admission. + await collection._lifecycle.cleanup() const message = `Source collection '${collection.id}' was manually cleaned up while live query '${electronics.id}' depends on it. ` + `Live queries prevent automatic GC, so this was likely a manual cleanup() call.` diff --git a/packages/powersync-db-collection/tests/powersync.test.ts b/packages/powersync-db-collection/tests/powersync.test.ts index 571da3dc24..90698431f4 100644 --- a/packages/powersync-db-collection/tests/powersync.test.ts +++ b/packages/powersync-db-collection/tests/powersync.test.ts @@ -90,7 +90,7 @@ describePowerSync(`PowerSync Integration`, () => { table: APP_SCHEMA.props.documents, }), ) - onTestFinished(() => collection.cleanup()) + onTestFinished(() => collection.cleanup({ dependents: true })) return collection } diff --git a/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx b/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx index 5fa1731d37..d843f89a61 100644 --- a/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx +++ b/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx @@ -470,7 +470,7 @@ describe(`agreement with useLiveQuery`, () => { describe(`collections kept from an abandoned render`, () => { // An eager source that writes `current` each time its sync run starts, so a - // restart after cleanup serves new rows from the same source object. + // reload serves new rows from the same source object. function makeRestartableSource(initial: Array) { let current = initial const source = createCollection({ @@ -491,10 +491,11 @@ describe(`collections kept from an abandoned render`, () => { cleanups.push(() => source.cleanup()) return { source, + // A reload starts a new sync run under mounted queries; its first + // commit replaces the rows. restartWith: async (next: Array) => { - await source.cleanup() current = next - source.startSyncImmediate() + await source.reload() }, } } diff --git a/packages/react-db/tests/source-id-reuse-oracle.test.tsx b/packages/react-db/tests/source-id-reuse-oracle.test.tsx index aacbe853bc..6b8d1c4e4a 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -358,7 +358,7 @@ describe(`React source ID reuse`, () => { await waitFor(() => expect(hook.result.current.data[0]?.value).toBe(`first`), ) - await act(async () => first.cleanup()) + await act(async () => first.cleanup({ dependents: true })) checkRerender([{ source: first }], { source: second }, () => hook.rerender({ current: second }), ) From 6e95d1a36309b8ba0da1e60c3d320e897096da91 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 16 Oct 2026 13:43:31 -0700 Subject: [PATCH 060/106] feat(db)!: fold persisted readiness into Collection status; add findProblems A persisted Collection now has one status. With initialRender network-first, a completed restore that installed rows makes the Collection ready once the deadline passes or the remote fails its first load. An empty restore stays loading, a remote answer before the restore waits for it, and a failed restore crashes. Remove persistedStatus, isPersistedReady, persistedError, preloadForInitialRender, isInitialRenderReady, and PERSISTED_READINESS from the observer, window controller, and all bindings. Pooled live queries no longer exclude persisted sources. Add findProblems(errors), which lists the nodes with a problem of their own in tree order, and check it in the source-health oracle. useLiveSuspenseQuery throws the first load's original cause. Rewrite the persisted-readiness oracle as an enumerated model over restore, remote, and deadline orderings (57 histories); three production mutants fail it. Update the error-handling, persistence, adapter, and Effect docs, the glossary, the coverage map, and the changeset. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 33 +- docs/collections/electric-collection.md | 15 +- docs/collections/query-collection.md | 13 +- .../HEALTH-CANDIDATE.md | 6 + .../designs/source-failure-and-health/PLAN.md | 6 + .../source-failure-and-health/README.md | 3 + .../USER-DOCS-DRAFT.md | 6 + docs/contributing/glossary.md | 6 +- docs/contributing/oracle-coverage.md | 47 +- docs/guides/collection-options-creator.md | 20 +- docs/guides/error-handling.md | 493 ++++-- docs/guides/live-queries.md | 6 +- docs/guides/sqlite-persistence.md | 52 +- packages/angular-db/src/index.ts | 13 - packages/angular-db/tests/conformance.test.ts | 6 - packages/db-sqlite-persistence-core/README.md | 64 +- .../src/persisted.ts | 68 +- .../tests/persisted-readiness-oracle.test.ts | 1499 ++++------------- .../references/query-adapter.md | 2 +- .../db/skills/db-core/custom-adapter/SKILL.md | 35 +- packages/db/src/collection/error-tree.ts | 14 + packages/db/src/collection/index.ts | 1 + packages/db/src/index.ts | 1 - packages/db/src/live-query-observer.ts | 268 +-- .../db/src/live-query-window-controller.ts | 11 +- packages/db/src/persisted-readiness.ts | 26 - packages/db/src/query/live/ARCHITECTURE.md | 4 +- packages/db/src/query/pooled-live-query.ts | 10 +- packages/db/tests/conformance/contract.ts | 3 - packages/db/tests/conformance/suite.ts | 127 +- .../live-query-window-controller.test.ts | 41 - .../db/tests/source-health-oracle.test.ts | 28 +- packages/react-db/src/useLiveInfiniteQuery.ts | 7 - packages/react-db/src/useLiveQuery.ts | 6 +- packages/react-db/src/useLiveSuspenseQuery.ts | 24 +- packages/react-db/tests/conformance.test.tsx | 3 - .../useLiveSuspenseQuery-recovery.test.tsx | 158 -- .../tests/useLiveSuspenseQuery.test.tsx | 150 -- packages/solid-db/src/useLiveQuery.ts | 30 +- packages/solid-db/tests/conformance.test.tsx | 3 - packages/solid-db/tests/useLiveQuery.test.tsx | 113 -- .../src/useLiveInfiniteQuery.svelte.ts | 9 - packages/svelte-db/src/useLiveQuery.svelte.ts | 20 - .../tests/conformance.svelte.test.ts | 3 - packages/vue-db/src/useLiveInfiniteQuery.ts | 10 - packages/vue-db/src/useLiveQuery.ts | 13 - packages/vue-db/tests/conformance.test.ts | 3 - 47 files changed, 871 insertions(+), 2608 deletions(-) delete mode 100644 packages/db/src/persisted-readiness.ts diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index d90c2038d4..2d594d6a2d 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -4,6 +4,7 @@ '@tanstack/electric-db-collection': minor '@tanstack/powersync-db-collection': minor '@tanstack/query-db-collection': minor +'@tanstack/rxdb-db-collection': minor '@tanstack/trailbase-db-collection': minor '@tanstack/react-db': minor '@tanstack/vue-db': minor @@ -12,16 +13,30 @@ '@tanstack/angular-db': minor --- -Collections now separate two questions: whether data can be shown, and whether it is still kept up to date. +Collections now answer two questions separately: can the data be shown (`status`), and is it still kept up to date (`errors`). Once a query has shown data, a network or backend failure never takes it off the screen. -`status` and `isError` say whether data can be shown: they describe the initial load of a sync run, or a crash that needs a restart. Any other failure after a Collection is ready no longer moves it to `error`: its rows stay, and the problem appears in the new `collection.health`, the `health:change` event, and the `errors` tree that every live-query hook returns. `errors` has one node per query alias with each node's status and error details, keeps its object identity while unchanged, and is plain data apart from each error's `cause`. Effects receive the same tree through `onHealthChange`. +- `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It is permanent for the instance and reaches every live query and Effect above it. `error` now means only "the first load failed". +- Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. +- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads the failed sources, then the query. It rejects on a `crashed` Collection. +- `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. +- Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. Breaking changes and migration: -- An initial error is final for its sync run. A later successful sync no longer moves a Collection from `error` back to `ready`. Restart instead: `await collection.cleanup()` then `await collection.preload()`. For query-db, a successful refetch after an initial failure applies its rows but leaves the status `error` until a restart. -- After a Collection is ready, sync failures no longer set `status` to `error` or `isError` to `true`. To show a "data may be out of date" message, read `errors.hasErrors` from the hook, or `collection.health`. -- A Collection that can no longer serve reads or accept writes crashes: a closed IndexedDB connection or a failed local SQLite persistence write moves it to `error` even after it was ready. Every live query and Effect over a Collection in `error` now fails too, whether the cause is an initial error or a crash. -- Adapter authors: call `markError(error)` when you give up. Before the first `markReady()` it fails the sync run; after it, it is a final health error and the rows stay. While you keep trying after readiness, call `reportError(error)`, then `reportHealthy()` when sync works again. `markReady()` after `markError()` in the same sync run now throws. -- Electric reports nothing while its stream retries, and reports a final health error when it gives up after readiness. TrailBase reports a final health error when its change stream ends, because it does not resubscribe. PowerSync reports a failed tracking rebuild as a health error that clears after a successful rebuild. query-db reports a failed refetch or result application after readiness as a health error that clears once every observed Query succeeds again. -- query-db removes `utils.lastError`, `utils.isError`, `utils.errorCount`, and `utils.clearError()`. Read `collection.health` or a live query's `errors` tree instead, and retry with `utils.refetch({ throwOnError: true })`. -- Infinite-query hooks (`useLiveInfiniteQuery` in React, Vue, and Svelte) remove `error`. A failed page load no longer sets `status` to `error`; it appears as a request error in the returned `errors` tree, and `fetchNextPage()` still resolves. +| Before | After | +| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| hook `isError` | `isLoadingError` (first load failed) or `isCrashed` | +| — | `isSyncError`: `ready` and the error tree has a problem | +| `status: 'error'` after a crash | `status: 'crashed'` | +| adapter `markError(error)` | `fail(error)`. After readiness it sets the node's `error` and keeps the rows. Later calls throw. | +| `reportError(error, { final })` | `reportError(error)` (sets `failureReason`, after readiness only) or `fail(error)` | +| `cleanup()` then `preload()` to recover | `reload()` | +| `cleanup()` while live queries or Effects read the Collection | rejects; unmount first or pass `{ dependents: true }` | +| `collection.health`, `health:change` | `collection.errors`, `errors:change` | +| node `phase`, `final`, `isError`, `instances.inError` | node `failureReason` or `error`, `instances.withErrors` | +| Effect `onHealthChange` | `onErrorsChange` | +| `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()`, `PERSISTED_READINESS` | `status` and `errors` | +| query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.errors`, `reload()` | +| infinite-query hooks' `error` (React, Vue, Svelte) | the root node's `error` in `errors`; `fetchNextPage()` still resolves | + +`reportError()` and `reportHealthy()` throw before the first `markReady()`. `markReady()` after a failed first load throws. Calls after a crash do nothing. `useLiveSuspenseQuery` now throws the first load's original cause instead of a generic error. diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 8ed61225fc..40d43491fc 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -408,15 +408,16 @@ recovery, coordinator invalidation retains its normal behavior. An eager or progressive resume cannot apply a partial update to an unknown row. The adapter rejects that batch, stops the stream, and records a reset so the -next sync starts from a full snapshot. Before the Collection is ready, this is -an initial error; after it, the rows stay and the Collection reports a final -health error in `collection.health` and in each live query's `errors` tree. -This does not silently retry the failed stream; a restart refetches. +next sync starts from a full snapshot. Before the Collection is ready, this +fails the first load (`status: 'error'`); after it, the rows stay and the +source node shows an `error` in `collection.errors` and in each live query's +`errors` tree. This does not silently retry the failed stream; +`collection.reload()` refetches. While the Electric stream retries a failed request, the Collection reports -nothing: it stays `loading` before its first up-to-date, and keeps its health -afterward. When the stream gives up, the failure is final: an initial error -before readiness, or a final health error after it. Complete updates from an explicit `replica: 'full'` stream remain valid. +nothing: it stays `loading` before its first up-to-date, and keeps its error tree +unchanged afterward. When the stream gives up, it calls `fail()`: a failed first load +before readiness, or the source node's `error` after it. Complete updates from an explicit `replica: 'full'` stream remain valid. On-demand streams can observe updates outside their loaded subsets; unknown partial rows are ignored, while transaction acknowledgement evidence is retained. Outside scoped recovery, complete rows published by persistence reloads or diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index d688f99b7c..29c1e3b126 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -682,12 +682,13 @@ The collection provides these utility methods via `collection.utils`: - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) - Returns the tracked Queries' `QueryObserverResult` values in tracked-key order - Preserves an `undefined` slot if a tracked Query is removed while the refetch starts -Failures appear in Collection health. After the Collection is ready, a failed -fetch or result application for any Query it observes is a health error that -is not final, because TanStack Query fetches again, and the next successful -application clears it. Read it from `collection.health` or a live query's -`errors` tree. A successful refetch does not move a Collection whose initial -load failed back to `ready`; restart it with `cleanup()` and `preload()`. +Failures appear in the error tree. Before the first successful result, a +failed fetch fails the first load (`status: 'error'`). After the Collection is +ready, a failed fetch or result application for any Query it observes sets the +node's `failureReason`, because TanStack Query fetches again, and the next +successful application clears it. Read it from `collection.errors` or a live +query's `errors` tree. To retry a failed first load, call +`collection.reload()`. ## Direct Writes diff --git a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md index bf02ab0a5a..dff0db1fe6 100644 --- a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md +++ b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md @@ -1,5 +1,11 @@ # Candidate: the source health channel and live-query error tree +> **Superseded (2026-10-16).** The error surface was redesigned: status +> `crashed`, node `failureReason`/`error`, `fail()`, `reload()`, and +> `collection.errors`. Read `error-surface/README.md` and `error-surface/RULES.md`, +> the shipped `docs/guides/error-handling.md`, and +> `packages/db/src/query/live/ARCHITECTURE.md` laws 14, 16, and 17. + Status: superseded candidate design, version 1. It came from a Field Lab design-grammar run. The shipped design differs; read `PLAN.md`, the shipped guides, and `packages/db/src/query/live/ARCHITECTURE.md` laws 16 and 17. diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index b4a60f1448..46529ddcf8 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -1,5 +1,11 @@ # Plan: final initial errors and the source health channel +> **Superseded (2026-10-16).** The error surface was redesigned: status +> `crashed`, node `failureReason`/`error`, `fail()`, `reload()`, and +> `collection.errors`. Read `error-surface/README.md` and `error-surface/RULES.md`, +> the shipped `docs/guides/error-handling.md`, and +> `packages/db/src/query/live/ARCHITECTURE.md` laws 14, 16, and 17. + Status: implemented on `feat/source-health-channel`; the pull request is next. It ships as one pull request, because it is a breaking change and a significant product change. It replaces #2129. Later decisions (2026-10-14): diff --git a/docs/contributing/designs/source-failure-and-health/README.md b/docs/contributing/designs/source-failure-and-health/README.md index c16e768249..57396754ca 100644 --- a/docs/contributing/designs/source-failure-and-health/README.md +++ b/docs/contributing/designs/source-failure-and-health/README.md @@ -1,5 +1,8 @@ # Source failure law: design grammar and TLA+ control +> **Historical.** This run predates the shipped error surface. See +> `error-surface/` for the current design record. + This directory holds a Field Lab design-grammar run on the source failure law that the maintainer chose on 2026-10-13. A source either keeps trying, becomes ready, or reports a final error for its sync attempt. The framework never heals diff --git a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md index a4f87e9928..20f15fba42 100644 --- a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md +++ b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md @@ -1,5 +1,11 @@ # Draft: what users would see (candidate v2) +> **Superseded (2026-10-16).** The error surface was redesigned: status +> `crashed`, node `failureReason`/`error`, `fail()`, `reload()`, and +> `collection.errors`. Read `error-surface/README.md` and `error-surface/RULES.md`, +> the shipped `docs/guides/error-handling.md`, and +> `packages/db/src/query/live/ARCHITECTURE.md` laws 14, 16, and 17. + This is a superseded proposal, not shipped behavior. It applies the repairs from the hostile assay and the fracture scan to `HEALTH-CANDIDATE.md`. The shipped API differs (for example, `reportError` has no `final` option, and diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 71dd93d789..5330917f8f 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -49,10 +49,10 @@ production queues, caches, or semantic helpers merely to share their names. | atomic publication | One publication boundary at which state, events, and consumers observe the same result without an intermediate public state. | Any individual `commit()` call or source snapshot. | | readiness | Evidence that a named consumer may proceed. Always qualify Collection readiness, subscription readiness, or initial-query readiness; they settle at different boundaries. | Provider completion or publication in general. | | persisted restore | The eager persisted source Collection's startup read and publication of its durable rows, including an empty snapshot, in the current sync run. | Upstream/provider readiness or an SSR seed. | -| persisted readiness | An opt-in live-query signal that every source Collection completed persisted restore in the current sync run; it is unavailable if any source did not opt in. | Collection readiness, network availability, or row nonemptiness. | +| 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 Suspense gate that prefers Collection readiness until a configured deadline or network failure, then permits a completed persisted restore. A failed client query stream counts as network failure; a failed derived query does not. The gate does not stop network sync. | The persisted-readiness status itself or a change to ordinary live-query row publication. | +| 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. | `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 @@ -69,7 +69,7 @@ current. After Collection readiness, only a crash changes status. | initial gate | The role of Collection status during a sync run's first load. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `fail()`. After Collection readiness in a sync run, sync problems never change the status. | The error tree or a live query's request errors. | | loading error | The `fail()` that ends a sync run's first load in status `error` (`isLoadingError`). It is final for its sync run: `markReady()` afterward throws, and only a new sync run can reach `ready`. | Sync error or a retry. | | crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It is permanent for the Collection instance. After it, sync adapter calls do nothing. | Loading error, sync error, or cleanup. | -| sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error, `persistedError`, or subscription status. | +| sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | | request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Sync error or request data. | | error tree | The `errors` value of a Collection, live query, or Effect: one node for a Collection; for a query, one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `errors:change` announces a change. | An error list. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 5a5c42a885..97935f27dc 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2223,37 +2223,24 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. The persisted-readiness oracle in `packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts` -owns the opted-in eager restore boundary through the persisted wrapper, -Collection, live query, and observer. A finite two-source model checks all 36 -opt-in/outcome combinations independently of the observer classifier. Its -controlled persistence histories check nonempty -and empty restore while upstream remains pending, all-source conjunction, -mixed opt-out, nested live-query sources, exact failure, SSR seed handoff, -stale cleanup/restart work, an already-restored source, and a two-source -history in which A restores, restarts, and remains pending -after B restores. A new query's persisted readiness and initial-render wait -stay gated until A restores in its current sync run. This history uses controlled -adapter reads and does not claim that an old query survives source cleanup. -The remaining controlled histories cover a network-first deadline, -early network success and failure, network failure before nonempty restore, -client query stream failure before or after empty and nonempty restore or before -observer construction, -joined-source deadline composition, and a derived-query error alone or alongside -a failed client stream that must not be mistaken for network failure. It checks a -separate initial-render wait that leaves network `preload()` unchanged. The -framework conformance suite checks the projected signals in React, Vue, -Svelte, Solid, and Angular, including exact identity for a plain-object restore -error after notification. The window-controller test checks infinite-query -projection, and focused React and Solid tests check Suspense gating. The React -tests check client-stream failure before and after completed empty and nonempty -restore, including a failed record already present before mount. They also -check error-boundary handling for derived-query failures with and without a -failed client stream. A replacement client record arriving during an -initial-render wait has no persisted-fallback witness yet. +owns when a persisted Collection with network-first initial rendering opens +from restored rows. A small model enumerates every ordering of the restore +outcome (rows, empty, failed), the remote answer (ready, fail, none), and the +deadline, 57 histories. After every event it compares Collection status, the +node's `error` message, the visible rows while `ready`, and the status of a live +query over the Collection. The laws: restored rows open the Collection once the +deadline passes or the remote fails; an empty restore stays `loading`; a remote +answer before the restore completes waits for it; a failed restore crashes. +Separate cases check that a Collection without `initialRender` waits for the +network, the three-second default deadline, and configuration rejection. +Mutants that let an empty restore open, skip the deadline, or skip the +remote-failure opening each fail the oracle. + This owner uses a controlled persistence adapter, not native SQLite or a real -network provider; it does not prove browser scheduling or arbitrary histories. -The framework identity scenario does not cover every possible rejection value -or an error already present before a hook mounts. +network provider. Restart, cross-tab coordination, a remote that stalls without +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. ## Deferred contracts and evidence diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index 46965386c9..2184825778 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -79,7 +79,7 @@ function for proper garbage collection: ```typescript const sync: SyncConfig['sync'] = (params) => { - const { begin, write, commit, markReady, markError, collection } = params + const { begin, write, commit, markReady, fail, collection } = params // 1. Initialize connection to your sync engine const connection = initializeConnection(config) @@ -149,7 +149,7 @@ const sync: SyncConfig['sync'] = (params) => { console.error('Initial sync failed:', error) // No usable initial snapshot exists, and this example does not retry. // After readiness, report failures with reportError() instead. - if (collection.status === 'loading') markError(error) + if (collection.status === 'loading') fail(error) } } @@ -174,9 +174,9 @@ The sync process follows this lifecycle: 2. **write()** - Add changes to the pending transaction (buffered until commit) 3. **commit()** - Apply all changes atomically to the collection state 4. **markReady()** - Signal that a usable initial snapshot exists -5. **markError(error?)** - Signal that you stopped trying. Before the Collection is ready, this fails the sync run: readiness waits reject with the cause, and `markReady()` afterward throws. After it, this is a final health error: the status and rows stay. Only a restart starts over -6. **reportError(error)** - After the Collection is ready, report a sync problem while you keep trying, without changing its status or rows -7. **reportHealthy()** - After the Collection is ready, report that sync works again. It clears the problem from `reportError()` +5. **fail(error)** - Signal that you stopped trying. Before the Collection is ready, this fails the first load: the status becomes `error`, readiness waits reject with the cause, and `markReady()` afterward throws. After it, the node shows an `error`: the status and rows stay. Every later call in this sync run throws. Only `reload()` starts a new run +6. **reportError(error)** - After the Collection is ready, report a sync problem while you keep trying. It sets the node's `failureReason` without changing its status or rows +7. **reportHealthy()** - After the Collection is ready, report that sync works again. It clears the `failureReason`, and does nothing when nothing is wrong ### Reporting failures @@ -185,14 +185,14 @@ still kept up to date. Follow these rules so both stay truthful: 1. While you retry the initial sync, call nothing. The Collection stays `loading`. -2. Call `markError(error)` when you give up, before or after the first - `markReady()`. Do not call `markReady()` after it. +2. Call `fail(error)` when you give up, before or after the first + `markReady()`. Do not call anything after it. 3. After the first `markReady()`, call `reportError(error)` for a problem while you keep trying, and `reportHealthy()` when sync works again. Never discard rows because sync failed. ```ts -sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy }) => { +sync: ({ begin, write, commit, markReady, fail, reportError, reportHealthy }) => { let ready = false const stream = connect({ onData: (rows) => { @@ -207,7 +207,7 @@ sync: ({ begin, write, commit, markReady, markError, reportError, reportHealthy } }, onError: (error, { willRetry }) => { - if (!willRetry) markError(error) + if (!willRetry) fail(error) else if (ready) reportError(error) }, }) @@ -1022,7 +1022,7 @@ const collection = createCollection( ## Best Practices -1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `markError(error)` when you give up on the initial sync +1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `fail(error)` when you give up on the initial sync 2. **Report health after readiness** - Use `reportError()` and `reportHealthy()` once the Collection is ready; recovery from an initial error is a restart, not a later `markReady()` 3. **Clean up resources** - Return a cleanup function from sync to prevent memory leaks 4. **Batch operations** - Use begin/commit to batch multiple changes for better performance diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index cc53ec28b5..e75fbd53c5 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -71,12 +71,13 @@ For more details on schema validation and type transformations, see the [Schemas ## Query Collection Error Tracking -Query collections report failures through Collection health, like every other -Collection. Before the first successful result, a failed query is an initial -error: the status is `error`, and readiness waits reject with the cause. After -it, a failed refetch or result application is a health error that is not -final, because TanStack Query fetches again. The rows stay, and the next -successful result clears the error: +Query Collections report failures through the error tree, like every other +Collection. A `queryFn` failure before the first successful result fails the +first load: the status is `error`, and `preload()` rejects with the cause. Set +`retry` so that a short outage is retried inside that first load. After the +first result, a failed refetch sets the source node's `failureReason`, because +TanStack Query fetches again. The rows stay, and the next successful result +clears it: ```tsx import { createCollection } from "@tanstack/db" @@ -89,17 +90,18 @@ const syncedCollection = createCollection( queryKey: ["synced-data"], queryFn: fetchData, getKey: (item) => item.id, + retry: 3, }) ) function DataList() { - const { data, errors } = useLiveQuery((q) => + const { data, isSyncError } = useLiveQuery((q) => q.from({ item: syncedCollection }) ) return ( <> - {errors?.hasErrors && ( + {isSyncError && ( Unable to sync. Showing cached data. + ) } ``` -The phases: +### Reloading -- `initial`: the Collection's sync run failed, by an initial error or a crash. - Its `status` is `error`. -- `sync`: the sync adapter reported a problem after the Collection was ready. -- `request`: a request for more data, such as the next page or a newly - joined row, failed after the query was ready. The source Collection stays - healthy. The error clears when that data loads, or when the query stops - needing it. +`collection.reload()` starts a new sync run for a Collection. It works like a +sync-side truncate: nothing visible changes until the new run's first commit. -A Collection used twice in one query, for example in a self-join, has one node -per alias. An include has one node for all of its per-row child Collections, -with `instances` counting how many are in error. A nested live query's node -carries its own sources. +- The current rows, `status`, and any `error` stay on screen. The node shows + `reloading: true`. +- When the new run commits, its rows replace the old rows in one change, and + the node's problem clears. A Collection in `error` becomes `ready` when the + new run calls `markReady()`. +- If the new run fails, the old rows and status stay, the node shows the new + `error`, and the `reload()` promise rejects. -The tree keeps its object identity until something in it changes, so you can -pass it to `useMemo` or compare it with `===`. Apart from `cause`, it is plain -data that you can log or send: +On a live query, `reload()` reloads each of its sources that has an `error`, +then the query itself if it failed its first load. To reload one source +instead, call `reload()` on that source's Collection. When a failed source +becomes ready and no other source is in `error` or `crashed`, a query in +`error` restarts by itself and shows `loading` until it is ready. ```ts +// Retry a failed first load every 30 seconds until the backend is back. useEffect(() => { - if (errors?.hasErrors) reportToMonitoring(JSON.parse(JSON.stringify(errors))) -}, [errors]) + if (!isLoadingError) return + const id = setInterval(() => collection.reload().catch(() => {}), 30_000) + return () => clearInterval(id) +}, [isLoadingError, collection]) ``` -Server-side rendering does not transfer `errors`; the client builds its own -tree from its own sync. +Calling `reload()` while a reload is in flight returns the same promise. On a +`crashed` Collection it rejects with `CollectionReloadCrashedError`. + +Use `reload()` for retries the user or the app decides on. Retries inside one +attempt belong to the sync adapter: for example, set `retry` on a Query +Collection, so a short outage never reaches `isLoadingError`. + +### When a Collection crashes + +A Collection crashes when its local storage can no longer serve reads or +accept writes: + +- its IndexedDB connection closed (for example after a `versionchange` from + another tab); +- a local SQLite persistence write or restore failed; +- its sync function threw, or an internal invariant failed. + +The Collection moves to `crashed`, and so does every live query and Effect over +it, even after they were ready. A crash is permanent for that Collection +instance: `reload()` rejects. To recover, clean up the Collection together with +everything that reads from it, then create a new one. + +```ts +console.log(todos.subscribers) // live queries, Effects, and raw subscriptions +await todos.cleanup({ dependents: true }) +const freshTodos = createCollection(todosOptions()) +``` + +### Cleaning up a Collection + +`cleanup()` rejects with `CollectionHasSubscribersError` while a live query +with subscribers or an Effect still reads the Collection, because those readers +would lose their data. Unmount the components that read it first, or pass +`{ dependents: true }` to end its live queries and Effects first. Raw +`subscribeChanges()` subscriptions do not block cleanup and are not ended by +it. `collection.subscribers` lists every dependent with its `kind`. Collections +that nothing reads are cleaned up automatically after `gcTime`. + +### Persisted Collections + +A persisted Collection restores its rows from local storage before the remote +syncs. By default it becomes `ready` only through its sync adapter. With +`initialRender: { strategy: "network-first", networkTimeoutMs }`, restored rows +make it `ready` once the deadline passes or the remote fails its first load. An +empty restore keeps it `loading`, and a failed restore crashes it. See +[Render after SQLite restore](./sqlite-persistence.md#render-after-sqlite-restore). ### Effects -An Effect follows the same rules. A source that fails its initial load, or -that crashes, disposes the Effect through `onSourceError`. Any other problem -after the Effect's initial load does not dispose it. Subscribe to it instead: +An Effect ends when a source it reads fails its first load or crashes; it calls +`onSourceError(error)`. After its initial load, any other problem calls +`onErrorsChange(errors)` with the same tree a live query returns, and the +Effect keeps running. ```ts createEffect({ @@ -325,7 +441,7 @@ createEffect({ onBatch: (events) => { /* ... */ }, - onHealthChange: (errors) => { + onErrorsChange: (errors) => { if (errors.hasErrors) pauseAutomations() else resumeAutomations() }, @@ -334,7 +450,10 @@ createEffect({ ### Using Suspense and Error Boundaries (React) -For React applications, you can handle loading and error states with `useLiveSuspenseQuery`, React Suspense, and Error Boundaries: +`useLiveSuspenseQuery` suspends while `loading` and throws the first load's +cause to the nearest error boundary for `error`. It throws for `crashed` at any +time. Sync problems after the first data never reach the boundary; read +`errors` from the hook, as in TanStack Query. ```tsx import { useLiveSuspenseQuery } from "@tanstack/react-db" @@ -342,14 +461,14 @@ import { Suspense } from "react" import { ErrorBoundary } from "react-error-boundary" const TodoList = () => { - const { data, errors } = useLiveSuspenseQuery((query) => + const { data, isSyncError } = useLiveSuspenseQuery((query) => query.from({ todos: todoCollection }) ) // data is always defined here return ( <> - {errors?.hasErrors &&
    Todos may be out of date.
    } + {isSyncError &&
    Todos may be out of date.
    } {data.map((todo) => (
    {todo.text}
    ))} @@ -366,12 +485,65 @@ const App = () => ( ) ``` -`` handles the initial load and `` handles an initial -error. After the first render, the hook does not throw, as in TanStack Query: -problems appear in `errors`, and a crash sets `isError` while the last data -stays on screen. See the [React Suspense section in Live Queries](./live-queries#using-with-react-suspense) for more details. +See the [React Suspense section in Live Queries](./live-queries#using-with-react-suspense) for more details. + +### For sync adapter authors + +A sync adapter reports failures with three calls: + +| Call | Meaning | Effect | +| --- | --- | --- | +| `reportError(error)` | Something went wrong; I am still trying. | Sets `failureReason`. Only after the first ready. | +| `fail(error)` | I stopped. | Before the first ready: `status: 'error'`. After: the node's `error`; the rows stay. | +| `reportHealthy()` | Sync works again. | Clears `failureReason`. Only after the first ready. | + +```ts +sync: ({ begin, write, commit, markReady, reportError, fail, reportHealthy }) => { + let ready = false + const stream = connect({ + onData: (rows) => { + begin() + rows.forEach((row) => write({ type: "insert", value: row })) + commit() + if (ready) reportHealthy() + else markReady() + ready = true + }, + onError: (error, { willRetry }) => { + if (!willRetry) fail(error) + else if (ready) reportError(error) + }, + }) + return () => stream.close() +} +``` -When every eager SQLite persisted source opts in, a failed client query stream can produce a different initial result. React waits for every persisted restore. If each restore succeeds, React renders the query result. A derived-query failure still reaches the Error Boundary. See [Render after SQLite restore](./sqlite-persistence.md#render-after-sqlite-restore) for the option and its limits. +- Retry inside the first load before calling `fail`. While you retry before + the first ready, call nothing; the Collection stays `loading`. +- These calls follow the Collection's state machine, and a call that does not + fit it throws: `reportError` or `reportHealthy` before the first ready, + `markReady` after a failed first load, and any call after `fail`. Calls + after a crash do nothing. `reportHealthy` when nothing is wrong changes + nothing, so you can call it after every commit. +- An `error` from `fail` clears only through `reload()`, which calls your + `sync` function again for a new run. + +### Migrating + +| Before | After | +| --- | --- | +| `isError` | `isLoadingError` (first load failed) or `isCrashed` (storage broke) | +| `status: 'error'` after a crash | `status: 'crashed'` | +| `markError(error)` | `fail(error)` | +| `reportError(error, { final })` | `reportError(error)` or `fail(error)` | +| node `phase`, `final`, `isError` | node `failureReason` or `error` | +| node `instances.inError` | `instances.withErrors` | +| `cleanup()` then `preload()` to recover | `reload()` | +| `cleanup()` with live queries mounted | rejects; unmount first or `cleanup({ dependents: true })` | +| `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()` | `status` and `errors` | +| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `errors`, `reload()` | +| `collection.health`, `health:change` | `collection.errors`, `errors:change` | +| Effect `onHealthChange` | `onErrorsChange` | ## Transaction Error Handling @@ -655,11 +827,10 @@ const todoCollection = createCollection( When sync errors occur: - Error is logged to console: `[QueryCollection] Error observing query...` -- An initial failure marks the collection as `error` because no usable snapshot exists -- Readiness waits such as `preload()` and `toArrayWhenReady()` reject with the cause passed to `markError(error)` while the collection is in that initial error state -- A later refetch failure keeps the collection `ready` and preserves its cached data, and reports a health error that is not final, because TanStack Query fetches again -- After the collection is ready, the failure appears in `collection.health` and the `errors` tree -- A later successful refetch clears that health error, but it does not move an initial `error` collection back to `ready`; restart it with `cleanup()` and `preload()` +- A failure before the first successful result fails the first load: the status is `error`, and readiness waits such as `preload()` and `toArrayWhenReady()` reject with the cause +- A later refetch failure keeps the Collection `ready` with its cached data and sets the source node's `failureReason`, because TanStack Query fetches again +- A later successful refetch clears the `failureReason` +- To retry a failed first load, call `collection.reload()` ### Sync Write Errors @@ -714,47 +885,12 @@ await collection.cleanup() // Resolves successfully ## Error Recovery Patterns -### Collection Cleanup and Restart - -Cleanup ends the current sync run and releases the resources installed by its -`sync()` call. A later access can start a new sync run. If no live query or -Effect depends on the source Collection, restart it like this: - -```ts -if (todoCollection.status === "error") { - // Cleanup ends the current sync run and resets the collection - await todoCollection.cleanup() - - // Start a new sync run and wait for readiness - await todoCollection.preload() -} -``` - -Cleaning up a source Collection while a live-query Collection depends on it -puts that live query in a terminal error state. Restarting the source alone -does not revive the live query. If you own both Collections, clean up the -dependent live query first. Preloading it after source cleanup starts fresh -sync runs for both Collections and waits for the query's required data: +### Reload or Recreate -```ts -const todosQuery = createLiveQueryCollection((q) => - q.from({ todos: todoCollection }), -) -await todosQuery.preload() - -// Later, if todoCollection enters an error state: -await todosQuery.cleanup() -await todoCollection.cleanup() -await todosQuery.preload() -``` - -The same `todosQuery` object and its subscribers can receive later source -updates after this sequence. A framework hook that creates its own live-query -Collection may retain it across renders or remounts. Use a pre-created -live-query Collection when you need to control this cleanup and restart -sequence. A successful preload establishes Collection readiness; whether the -source has caught up with a remote service depends on its sync adapter. -Cleanup also disposes dependent Effects; recreate them after the source restarts. +To retry a failed first load or a stopped sync, call `reload()` on the +Collection or on the live query that reads it. See [Reloading](#reloading). A +crashed Collection cannot reload; clean it up with `{ dependents: true }` and +create a new one. See [When a Collection crashes](#when-a-collection-crashes). ### Graceful Degradation @@ -763,21 +899,23 @@ fails. The error tree tells you which source has the problem and whether its sync adapter is still trying: ```tsx +import { findProblems } from "@tanstack/db" + const TodoApp = () => { - const { data, errors } = useLiveQuery((query) => + const { data, errors, collection } = useLiveQuery((query) => query.from({ todos: todoCollection }) ) - const todosError = errors?.sources[0]?.error + const [problem] = errors ? findProblems(errors) : [] return (
    - {todosError && ( + {problem?.error && (
    - {todosError.final - ? "Sync stopped. Reload to try again." - : "Reconnecting. Showing saved data."} + Sync stopped.{" "} +
    )} + {problem?.failureReason &&
    Reconnecting. Showing saved data.
    } {data.map((todo) => )}
    ) @@ -949,7 +1087,7 @@ Thrown when calling `commit()` on a sync transaction that's already committed. 2. **Import specific error types** - Import only the error classes you need for better tree-shaking 3. **Always handle SchemaValidationError** - Provide clear feedback for validation failures -4. **Check both axes** - Use `isLoading` and `isError` for whether data can be shown, and `errors.hasErrors` for whether it may be stale +4. **Check both questions** - Use `status` (`isLoading`, `isLoadingError`, `isCrashed`) for whether data can be shown, and `isSyncError` or `errors` for whether it may be stale 5. **Handle transaction promises** - Always handle `when('settled')` rejections ## Example: Complete Error Handling @@ -999,7 +1137,8 @@ const todosQuery = createLiveQueryCollection((query) => ) const TodoApp = () => { - const { data, isError, isLoading, errors } = useLiveQuery(todosQuery) + const { data, isLoadingError, isCrashed, isLoading, isSyncError } = + useLiveQuery(todosQuery) const handleAddTodo = async (text: string) => { try { @@ -1022,21 +1161,23 @@ const TodoApp = () => { } } - const handleRestart = async () => { + const handleReload = async () => { try { - await todosQuery.cleanup() - await todoCollection.cleanup() - await todosQuery.preload() + await todosQuery.reload() } catch (error) { - alert(`Failed to restart todos: ${String(error)}`) + alert(`Failed to reload todos: ${String(error)}`) } } - if (isError) { + if (isCrashed) { + return
    Local storage failed. Reload the page.
    + } + + if (isLoadingError) { return (
    Todos could not load
    - +
    ) } @@ -1047,7 +1188,7 @@ const TodoApp = () => { return (
    - {errors?.hasErrors &&
    Todos may be out of date
    } + {isSyncError &&
    Todos may be out of date
    } diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 7e2446c3ff..9475d77be5 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -2357,8 +2357,8 @@ const effect = createEffect({ | `onExit` | `(event, ctx) => void \| Promise` (optional) | Called once for each row exiting the query result. | | `onBatch` | `(events, ctx) => void \| Promise` (optional) | Called once per graph run with the full unfiltered batch of delta events. | | `onError` | `(error, event) => void` (optional) | Called when `onEnter`, `onUpdate`, `onExit`, or `onBatch` throws or rejects. | -| `onHealthChange` | `(errors) => void` (optional) | Called with the Effect's error tree when a source's health or a request changes after the Effect's initial load. | -| `onSourceError` | `(error) => void` (optional) | Called when a source collection enters an error state (an initial error or a crash) or is cleaned up. The effect is automatically disposed after this fires. If not provided, the error is logged to `console.error`. | +| `onErrorsChange` | `(errors) => void` (optional) | Called with the Effect's error tree when a source's sync errors or a request error change after the Effect's initial load. | +| `onSourceError` | `(error) => void` (optional) | Called when a source collection fails its first load, crashes, or is cleaned up with `{ dependents: true }`. The effect is automatically disposed after this fires. If not provided, the error is logged to `console.error`. | | `skipInitial` | `boolean` (optional) | When `true`, deltas from the initial data load are suppressed. Only subsequent changes fire handlers. Defaults to `false`. | ### Delta Events @@ -2479,7 +2479,7 @@ createEffect({ }) ``` -If a source collection enters an error state (an initial error or a crash) or is cleaned up, the effect automatically disposes itself. Use `onSourceError` to handle this. Other problems after the effect's initial load do not dispose it; they reach `onHealthChange`: +If a source collection fails its first load, crashes, or is cleaned up with `{ dependents: true }`, the effect automatically disposes itself. Use `onSourceError` to handle this. Other problems after the effect's initial load do not dispose it; they reach `onErrorsChange`: ```ts createEffect({ diff --git a/docs/guides/sqlite-persistence.md b/docs/guides/sqlite-persistence.md index cb9ed3d929..6951c037bc 100644 --- a/docs/guides/sqlite-persistence.md +++ b/docs/guides/sqlite-persistence.md @@ -217,7 +217,7 @@ const syncedTodos = createCollection( Install `@tanstack/query-core` and `@tanstack/query-db-collection` to use this example. Change the URL to your server. You can wrap another Collection sync adapter in the same way. -The `initialRender` option is optional. It lets live queries report when SQLite restore completes. It also lets React and Solid Suspense use restored rows for the first render while the Collection is not ready. +The `initialRender` option is optional. It lets the Collection become ready from restored SQLite rows when the network is slow or unavailable. See [Render after SQLite restore](#render-after-sqlite-restore). Keep `syncedDatabase` open while `syncedTodos` runs. During normal shutdown, await `syncedTodos.cleanup()` and then `syncedDatabase.close?.()`. @@ -236,43 +236,43 @@ An on-demand cache is not yet a certified offline snapshot. The first managed ge ## Render after SQLite restore -`initialRender` requires an eager persisted Collection. It does not support `syncMode: 'on-demand'`, which has no complete startup snapshot. Set it on every source Collection in a live query. If one source does not opt in, the query cannot report persisted readiness or use the restored snapshot as its initial-render fallback. +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`. -Install the React binding to use the following components: +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: -```sh -npm install @tanstack/react-db -``` +- the SQLite restore completed and installed at least one row, and +- the deadline passed, or the sync adapter failed its first load. -A regular React live query exposes both readiness signals: +The status is the only signal. Every hook, Suspense, and `preload()` follow it, so you do not need a separate persisted flag: ```tsx import { useLiveQuery } from '@tanstack/react-db' function TodosView() { - const result = useLiveQuery((q) => q.from({ syncedTodos })) - const readiness = result.isReady - ? 'Collection ready' - : result.isPersistedReady - ? 'SQLite restore complete. Collection readiness pending' - : 'Waiting for Collection or SQLite restore' - + const { data, isLoading, isLoadingError, isSyncError } = useLiveQuery((q) => + q.from({ syncedTodos }), + ) + if (isLoading) return

    Loading todos...

    + if (isLoadingError) return

    Could not load todos.

    return ( <> -

    {readiness}

    -
      {result.data.map((todo) =>
    • {todo.title}
    • )}
    + {isSyncError &&

    Showing saved todos. Sync is having trouble.

    } +
      {data.map((todo) =>
    • {todo.title}
    • )}
    ) } ``` -`isReady` reports Collection readiness. `isPersistedReady` reports that every opted-in source finished its current eager restore, even when SQLite had no rows. These flags do not identify where each visible row came from. A regular `useLiveQuery` does not wait for the network-first deadline before it renders rows. +These rules keep `ready` meaningful: -The hooks also expose `persistedStatus` and `persistedError`. The status is `unavailable` if a source did not opt in, `loading` during restore, `ready` after every restore completes, or `error` if a restore fails. `persistedError` keeps the restore error. An SSR seed does not count as a SQLite restore. +- **An empty restore waits for the network.** The Collection stays `loading` until the sync adapter answers, so an empty list always means "no rows". A Collection that last synced to zero rows also waits, because the restore cannot tell the two cases apart. +- **The restore comes first.** If the sync adapter answers before the restore completes, the answer waits for the restore. A remote failure then opens the Collection from restored rows and shows the failure as a sync error (`isSyncError`, `collection.errors.error`). Without restored rows, the first load fails (`status: 'error'`). +- **A failed restore is a crash.** The Collection becomes `crashed`, because local storage cannot serve reads. +- **The deadline does not stop network sync.** Network changes apply over the restored rows when they arrive. -React, Solid, and Svelte read these values as properties. Angular reads signals such as `result.isPersistedReady()`. Vue reads refs such as `result.isPersistedReady.value`. +`initialRender` requires an eager persisted Collection. It does not support `syncMode: 'on-demand'`, which has no complete startup snapshot. -React and Solid Suspense can use the same option for network-first initial rendering. This React component uses the `syncedTodos` Collection configured above: +Suspense uses the same status. This React component uses the `syncedTodos` Collection configured above: ```tsx import { Suspense } from 'react' @@ -292,15 +292,9 @@ function TodosScreen() { } ``` -Place `TodosScreen` inside an [Error Boundary](./error-handling.md#using-suspense-and-error-boundaries-react) to handle initial failures. - -Suspense prefers Collection readiness. If it remains pending after `networkTimeoutMs`, a completed SQLite restore can release the first render. The default is 3 seconds. Set `networkTimeoutMs: 0` to allow fallback as soon as restore completes. For a query with multiple sources, the longest configured deadline applies. The deadline does not stop network sync. - -A source failure can allow fallback before the deadline. React also waits for restore if its client query stream fails before the first render, including before the component mounts. Once every restore succeeds, React renders the derived query result, even if it is empty. - -A derived-query failure still reaches the surrounding React Error Boundary. If the network-first wait rejects, its error reaches that boundary. A successful network load can render even if restore failed or a source did not opt in. +Place `TodosScreen` inside an [Error Boundary](./error-handling.md#using-suspense-and-error-boundaries-react) to handle a failed first load or a crash. -Persisted readiness does not prove current authorization. Isolate SQLite data by user or tenant. If an authorization failure must hide cached rows, block the view separately. The fallback does not classify source errors. +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. ## Browser tabs and Electron renderers @@ -358,7 +352,7 @@ The Node package prunes its applied transaction log by default. Its [README](htt An unavailable browser storage capability raises `PersistenceUnavailableError`. A durable write failure raises `PersistedCollectionDurabilityError` and crashes the Collection: it moves to its error state even after it was ready, and every live query and Effect over it fails too. Restart it with `cleanup()` and `preload()`. -Failures from the wrapped sync adapter keep their usual meaning. Before the Collection is ready, the adapter's `markError` fails the sync run. After it, the adapter's `reportError` and `reportHealthy` appear in `collection.health` and in each live query's `errors` tree, and the persisted rows stay. In a follower tab, a failed request routed to the leader appears as a request error on the query that asked for the data. +Failures from the wrapped sync adapter keep their usual meaning. Before the Collection is ready, the adapter's `fail` fails the first load (see [Render after SQLite restore](#render-after-sqlite-restore) for `initialRender`). After it, the adapter's `reportError`, `reportHealthy`, and `fail` appear in `collection.errors` and in each live query's `errors` tree, and the persisted rows stay. A failure of persistence itself crashes the Collection. In a follower tab, a failed request routed to the leader appears as a request error on the query that asked for the data. If a coordinated write loses its response, the result can be uncertain. `IndeterminateCommitError` means the application must check the durable state before it retries that write. diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 3c6fe924d1..03539c901f 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -25,7 +25,6 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -46,9 +45,6 @@ export interface InjectLiveQueryFlags { isLoading: Signal /** A signal indicating whether the collection is ready */ isReady: Signal - persistedStatus: Signal - isPersistedReady: Signal - persistedError: Signal /** A signal indicating whether the collection is idle */ isIdle: Signal /** The first load failed: there is nothing to show. */ @@ -220,8 +216,6 @@ export function injectLiveQuery(opts: any) { const value = statusValue() return value === `idle` && !collection() ? `disabled` : value }) - const persistedStatus = signal(`unavailable`) - const persistedError = signal(undefined) const errors = signal(undefined) // Returns single item for singleResult collections, array otherwise @@ -246,8 +240,6 @@ export function injectLiveQuery(opts: any) { internalData.set(newData) const snapshot = observer.getSnapshot() statusValue.set(snapshot.status) - persistedStatus.set(snapshot.persistedStatus) - persistedError.set(snapshot.persistedError) errors.set(snapshot.errors) } @@ -263,8 +255,6 @@ export function injectLiveQuery(opts: any) { // Handle null collection (disabled query) if (!currentCollection) { statusValue.set(`disabled` as const) - persistedStatus.set(`unavailable`) - persistedError.set(undefined) errors.set(undefined) state.set(new Map()) internalData.set([]) @@ -310,9 +300,6 @@ export function injectLiveQuery(opts: any) { status, isLoading: computed(() => status() === `loading`), isReady: computed(() => status() === `ready` || status() === `disabled`), - persistedStatus, - isPersistedReady: computed(() => persistedStatus() === `ready`), - persistedError, isIdle: computed(() => status() === `idle`), isLoadingError: computed(() => status() === `error`), isCrashed: computed(() => status() === `crashed`), diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index b7ae5ab16a..40d6663598 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -135,9 +135,6 @@ function makeHandle(result: any, destroy: () => void): LiveQueryHandle { state: result.state(), status: result.status(), isReady: result.isReady(), - persistedStatus: result.persistedStatus(), - isPersistedReady: result.isPersistedReady(), - persistedError: result.persistedError(), isLoadingError: result.isLoadingError(), isCrashed: result.isCrashed(), isSyncError: result.isSyncError(), @@ -372,9 +369,6 @@ it(`preserves raw result types through the actual driver reader`, () => { state: new Map(), status: `disabled`, isReady: false, - persistedStatus: `unavailable`, - isPersistedReady: false, - persistedError: undefined, isLoadingError: false, isCrashed: false, isSyncError: false, diff --git a/packages/db-sqlite-persistence-core/README.md b/packages/db-sqlite-persistence-core/README.md index 6205a7da6d..e93fca774a 100644 --- a/packages/db-sqlite-persistence-core/README.md +++ b/packages/db-sqlite-persistence-core/README.md @@ -88,9 +88,8 @@ while still handling per-collection schema versions correctly. ### Network-first initial rendering -Set `initialRender` on every eager persisted source Collection in a query to -let React or Solid Suspense render restored SQLite rows when the network is -slow or unavailable: +Set `initialRender` to let the Collection become ready from restored SQLite +rows when the network is slow or unavailable: ```ts const todos = createCollection( @@ -105,48 +104,31 @@ const todos = createCollection( }, }), ) - -const result = useLiveQuery((q) => q.from({ todos })) -if (result.isPersistedReady) { - // The eager persisted restore completed, even if the network is pending. -} ``` -The live-query observer and framework hooks expose `persistedStatus` -(`unavailable`, `loading`, `ready`, or `error`), `isPersistedReady`, and -`persistedError`. `ready` means every source Collection in the query opted in -and completed its current eager persisted restore; an empty database also -counts as ready. One unconfigured source makes the query's persisted status -`unavailable`. A failed restore reports its original error through -`persistedError`. An SSR seed does not count as persisted restore. - -The initial render prefers Collection readiness. If the network has not made -the query ready after `networkTimeoutMs`, Suspense may use a completed -persisted restore. A network failure allows that fallback sooner. If the -restore is still loading, the query waits for it or for the network. The -deadline never cancels network sync. The default deadline is 3 seconds; -`networkTimeoutMs: 0` allows fallback as soon as restore finishes. For a query -with multiple opted-in sources, the longest deadline applies. An unconfigured -source keeps the existing network-readiness gate. A query failure that did not -come from a source is still an error, not a network fallback. Ordinary -`useLiveQuery` row updates and Collection `status`, `isReady`, and `preload()` are unchanged; -`status` is a Collection lifecycle signal, not a pure network-health signal. - -In React, a client query stream failure before the first render also allows a -completed persisted restore to release Suspense. React waits if the stream -fails before restore completes, including when it failed before the component -mounted. The derived query result can be empty. A derived-query failure still -reaches the Error Boundary. - -Persisted readiness does not prove current authorization. A source error can -permit restored rows to render even when that error reports an authorization -failure. Applications with user- or tenant-specific data must isolate persisted -storage by identity and clear or replace it when the identity changes. If a -policy denial must hide cached rows, gate the view separately; `initialRender` -does not classify source errors. +The Collection waits for its sync adapter for `networkTimeoutMs` (default 3 +seconds). After the deadline, or as soon as the sync adapter fails its first +load, a completed restore that installed rows makes the Collection `ready`. +Collection `status` is the only signal; hooks, Suspense, and `preload()` +follow it. + +- An empty restore keeps the Collection `loading` until the network answers. +- A sync adapter answer that arrives before the restore completes waits for + it. A remote failure then opens the Collection from restored rows and shows + as a sync error on `collection.errors`; without rows, the first load fails. +- A failed restore crashes the Collection. +- The deadline never cancels network sync. + +Without `initialRender`, the Collection becomes ready only through its sync +adapter. + +Restored rows do not prove current authorization. Applications with user- or +tenant-specific data must isolate persisted storage by identity and clear or +replace it when the identity changes. If a policy denial must hide cached rows, +gate the view separately. `initialRender` is rejected with `syncMode: 'on-demand'`, since an on-demand -Collection has no complete startup snapshot to certify. +Collection has no complete startup snapshot. ### Coordinator contract diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 10b72af469..8cd559c827 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -1,7 +1,6 @@ import { NoPendingSyncTransactionCommitError, NoPendingSyncTransactionWriteError, - PERSISTED_READINESS, SYNC_PERSISTENCE_PROTOCOL, SYNC_PERSISTENCE_VERSION, SyncTransactionAbortedError, @@ -51,8 +50,6 @@ import type { LoadSubsetFn, LoadSubsetOptions, PendingMutation, - PersistedReadinessSnapshot, - PersistedReadinessSource, SyncAppliedReceipt, SyncConfig, SyncConfigRes, @@ -783,34 +780,33 @@ export interface PersistedCollectionUtils extends UtilsRecord { forceReloadSubset?: (options: LoadSubsetOptions) => Promise | void } -class PersistedReadinessTracker implements PersistedReadinessSource { +/** The local-restore boundary of one persisted Collection's sync run. */ +type PersistedReadinessSnapshot = + | { status: `loading`; error?: never } + | { status: `ready`; error?: never } + | { status: `error`; error: unknown } + +/** + * Tracks the local restore for network-first initial rendering. The + * wrapper's sync function reads it to open the Collection from restored rows. + */ +class PersistedReadinessTracker { private snapshot: PersistedReadinessSnapshot = { status: `loading` } - private networkDeadlineAt: number | undefined private readonly listeners = new Set<() => void>() constructor(readonly networkTimeoutMs: number) {} getSnapshot = (): PersistedReadinessSnapshot => this.snapshot - getOrStartNetworkDeadline = (): number => { - return (this.networkDeadlineAt ??= Date.now() + this.networkTimeoutMs) - } - subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener) return () => this.listeners.delete(listener) } set(snapshot: PersistedReadinessSnapshot): void { - const deadlineRestarted = - snapshot.status === `loading` && this.networkDeadlineAt !== undefined - if (deadlineRestarted) { - this.networkDeadlineAt = Date.now() + this.networkTimeoutMs - } if ( this.snapshot.status === snapshot.status && - this.snapshot.error === snapshot.error && - !deadlineRestarted + this.snapshot.error === snapshot.error ) { return } @@ -5699,6 +5695,7 @@ function createWrappedSyncConfig< return { found: false as const } } let fullStartPromise: Promise | null = null + let stopNetworkFirstGate = () => {} let sourceResultPromise: Promise | null = null let resolveSourceResultAssigned!: () => void const sourceResultAssigned = new Promise((resolve) => { @@ -5749,16 +5746,30 @@ function createWrappedSyncConfig< }) } const getTerminalFailure = () => runtime.getCurrentTerminalFailure() + // 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. + const openFromDisk = () => { + if (!persistedReadiness || isCleanedUp() || getTerminalFailure()) return + if (persistedReadiness.getSnapshot().status !== `ready`) return + if (params.collection.status !== `loading`) return + if (params.collection.size === 0) return + params.markReady() + } const reportUpstreamError = (error: unknown) => { if (persistedReadiness?.getSnapshot().status !== `loading`) { + openFromDisk() runtime.reportSyncError(error, `sync adapter`) return } - // An early network error must not stop the live-query graph before - // the persisted baseline has published its rows. + // A remote that fails before the restore finishes waits for it: the + // restored rows open the Collection and the failure shows as its + // error; without rows, the first load fails. void (fullStartPromise ?? runtime.ensureStarted()).then( () => { if (!isCleanedUp() && !getTerminalFailure()) { + openFromDisk() runtime.reportSyncError(error, `sync adapter`) } }, @@ -6511,6 +6522,20 @@ function createWrappedSyncConfig< }) } fullStartPromise = runtime.ensureStarted() + if (persistedReadiness) { + let deadlinePassed = false + const deadline = setTimeout(() => { + deadlinePassed = true + openFromDisk() + }, persistedReadiness.networkTimeoutMs) + const stopReadiness = persistedReadiness.subscribe(() => { + if (deadlinePassed) openFromDisk() + }) + stopNetworkFirstGate = () => { + clearTimeout(deadline) + stopReadiness() + } + } // Startup can fail before the source reaches markReady or loadSubset, // which are the two eventual consumers of this outer adopting promise. // The runtime-owned inner promise still installs and reports the exact @@ -6552,6 +6577,7 @@ function createWrappedSyncConfig< for (const acquisition of acquisitions.values()) { acquisition.cancelAdmissionWait?.() } + stopNetworkFirstGate() acquisitions.clear() pendingPublicationTransactions.length = 0 for (const transaction of transactionStack) { @@ -6920,9 +6946,6 @@ export function persistedCollectionOptions< persistedReadinessTracker, ), persistence, - ...(persistedReadinessTracker - ? { [PERSISTED_READINESS]: persistedReadinessTracker } - : {}), } return withCollectionConfigFactory( @@ -7038,9 +7061,6 @@ export function persistedCollectionOptions< ...localOnlyOptions, id: collectionId, persistence, - ...(persistedReadinessTracker - ? { [PERSISTED_READINESS]: persistedReadinessTracker } - : {}), sync: createLoopbackSyncConfig(runtime), onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts index 0275831056..2f6790cca0 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts @@ -1,38 +1,175 @@ -import { describe, expect, it, vi } from 'vitest' -import { - DbClient, - PERSISTED_READINESS, - createCollection, - createLiveQueryCollection, - createLiveQueryObserver, - eq, - getPersistedReadinessSource, -} from '@tanstack/db' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' import { persistedCollectionOptions } from '../src/index.js' import type { PersistenceAdapter } from '../src/index.js' +import type { SyncConfig } from '@tanstack/db' + +/** + * # When does a persisted Collection open from restored rows? + * + * A persisted Collection keeps one status: `status` says whether the data can + * be shown, wherever the rows came from. Without `initialRender`, the + * Collection waits for its sync adapter, as any Collection does. With + * `initialRender: { strategy: 'network-first', networkTimeoutMs }`, it prefers + * the network for `networkTimeoutMs`. Once that deadline passes, or the remote + * fails, a completed restore that installed rows opens the Collection: status + * `ready` with the restored rows. Problems the remote reports after that are + * the Collection's sync errors, and its rows stay. + * + * Three boundaries keep `ready` honest: + * + * - An empty restore never opens the Collection. It stays `loading` until the + * remote answers, so an empty list always means "no rows", not "unknown". + * (A Collection that synced to zero rows also waits for the network; the + * restore cannot tell the two apart.) + * - The restore comes first. A remote that answers before the restore + * finishes waits for it, so remote rows apply over restored ones. A remote + * that became ready then makes the Collection `ready`. A remote that failed + * lets restored rows open the Collection, and the failure shows as its + * `error`; with no rows the first load fails (`status: 'error'`). + * - A failed restore is a crash: local storage cannot serve reads. + * + * ## The model + * + * `expected` replays the events of one history over a small state: the + * Collection's status, whether the restore finished and with how many rows, + * whether the deadline passed, the remote answer that waits for the restore, + * and the error the node shows. It reads no production state. + * + * ## The history grammar + * + * A history orders up to three events: the restore settles (with rows, empty, + * or failed), the remote answers (it becomes ready, or it fails), and the + * deadline passes. Every ordering of every combination is generated. + * + * ## The comparison + * + * After every event the driver compares the Collection's status, the message + * of its node's `error`, and, while it is `ready`, the keys of its rows. A + * live query over the Collection follows its status. + * + * ## Limits + * + * The adapter is a controlled in-memory persistence adapter, not native + * SQLite. Restarts, cross-tab coordination, and on-demand persistence are + * outside this owner (on-demand rejects network-first configuration). + */ type Row = { id: string; value: string } -type ModelOutcome = `loading` | `ready` | `error` -type ModelSource = { - optedIn: boolean - outcome: ModelOutcome - error: unknown +type Restore = `rows` | `empty` | `failed` +type Remote = `ready` | `fail` +type Event = + | { kind: `restore`; outcome: Restore } + | { kind: `remote`; outcome: Remote } + | { kind: `deadline` } + +const TIMEOUT = 100 +const DISK_ROW: Row = { id: `disk`, value: `restored` } +const REMOTE_ROW: Row = { id: `remote`, value: `synced` } +const RESTORE_FAILURE = `restore failed` +const REMOTE_FAILURE = `remote gave up` + +// --------------------------------------------------------------------------- +// Independent model +// --------------------------------------------------------------------------- + +type Expected = { + status: `loading` | `ready` | `error` | `crashed` + error: string | null + rows: Array } -function expectedPersistedSnapshot(sources: ReadonlyArray) { - if (sources.some((entry) => !entry.optedIn)) { - return { status: `unavailable`, error: undefined } +function expected(events: ReadonlyArray): Array { + let status: Expected[`status`] = `loading` + let restore: Restore | `pending` = `pending` + let deadline = false + let remoteFailed = false + let waitingRemote: Remote | undefined + let error: string | null = null + const rows = new Set() + + // Restored rows may open the Collection once the network had its chance. + const openFromDisk = () => { + if ( + status === `loading` && + restore === `rows` && + (deadline || remoteFailed) + ) + status = `ready` } - const failed = sources.find((entry) => entry.outcome === `error`) - if (failed) return { status: `error`, error: failed.error } - return { - status: sources.some((entry) => entry.outcome === `loading`) - ? `loading` - : `ready`, - error: undefined, + const applyRemoteReady = () => { + rows.add(REMOTE_ROW.id) + if (status === `loading`) status = `ready` + } + const applyRemoteFailure = () => { + openFromDisk() + if (status === `ready`) error = REMOTE_FAILURE + else if (status === `loading`) { + status = `error` + error = REMOTE_FAILURE + } + } + + return events.map((event) => { + if (status !== `crashed`) { + if (event.kind === `restore`) { + restore = event.outcome + if (event.outcome === `failed`) { + status = `crashed` + error = RESTORE_FAILURE + } else { + if (event.outcome === `rows`) rows.add(DISK_ROW.id) + if (waitingRemote === `ready`) applyRemoteReady() + else if (waitingRemote === `fail`) applyRemoteFailure() + else openFromDisk() + } + } else if (event.kind === `deadline`) { + deadline = true + openFromDisk() + } else { + if (event.outcome === `fail`) remoteFailed = true + if (restore === `pending`) waitingRemote = event.outcome + else if (event.outcome === `ready`) applyRemoteReady() + else applyRemoteFailure() + } + } + return { + status, + error, + rows: status === `ready` ? [...rows].sort() : [], + } + }) +} + +// --------------------------------------------------------------------------- +// History grammar +// --------------------------------------------------------------------------- + +function permutations(items: ReadonlyArray): Array> { + if (items.length <= 1) return [[...items]] + return items.flatMap((item, index) => + permutations([...items.slice(0, index), ...items.slice(index + 1)]).map( + (rest) => [item, ...rest], + ), + ) +} + +const histories: Array> = [] +for (const restore of [`rows`, `empty`, `failed`] as const) { + for (const remote of [`ready`, `fail`, undefined] as const) { + for (const deadline of [true, false]) { + const events: Array = [{ kind: `restore`, outcome: restore }] + if (remote) events.push({ kind: `remote`, outcome: remote }) + if (deadline) events.push({ kind: `deadline` }) + histories.push(...permutations(events)) + } } } +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- + function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void @@ -43,20 +180,17 @@ function deferred() { return { promise, resolve, reject } } -function recordingAdapter(rows: ReadonlyArray) { - const load = deferred() +// A persistence adapter whose restore read waits for the history to settle it. +function controlledAdapter() { + const load = deferred>() + void load.promise.catch(() => undefined) const adapter: PersistenceAdapter = { - loadSubset: async () => { - await load.promise - return rows.map((value) => ({ key: value.id, value })) - }, + loadSubset: async () => + (await load.promise).map((value) => ({ key: value.id, value })), loadResumeSnapshot: async (_id, options) => { - if (options?.includeRows !== false) await load.promise + const rows = options?.includeRows === false ? [] : await load.promise return { - rows: - options?.includeRows === false - ? [] - : rows.map((value) => ({ key: value.id, value })), + rows: rows.map((value) => ({ key: value.id, value })), keySet: { status: `consistent` }, collectionMetadata: [], latestTerm: 0, @@ -71,412 +205,158 @@ function recordingAdapter(rows: ReadonlyArray) { return { adapter, load } } -function source( - id: string, - adapter: PersistenceAdapter, - optIn: boolean, - networkTimeoutMs = 0, -) { - return createCollection( +function networkFirst(id: string, initialRender = true) { + const fixture = controlledAdapter() + let remote: Parameters[`sync`]>[0] | undefined + const collection = createCollection( persistedCollectionOptions({ id, getKey: (row) => row.id, - persistence: { adapter }, - ...(optIn + persistence: { adapter: fixture.adapter }, + ...(initialRender ? { initialRender: { strategy: `network-first` as const, - networkTimeoutMs, + networkTimeoutMs: TIMEOUT, }, } : {}), - // Upstream never declares Collection readiness. Local restore is a - // separately observable boundary even while this stays loading. - sync: { sync: () => ({}) }, + sync: { + sync: (params) => { + remote = params + }, + }, }), ) -} - -async function checkWithCleanup( - check: () => Promise | void, - cleanups: ReadonlyArray<() => Promise | unknown>, -): Promise { - let hasPrimaryFailure = false - let primaryFailure: unknown - try { - await check() - } catch (error) { - hasPrimaryFailure = true - primaryFailure = error + return { + collection, + restore: (outcome: Restore) => + outcome === `failed` + ? fixture.load.reject(new Error(RESTORE_FAILURE)) + : fixture.load.resolve(outcome === `rows` ? [DISK_ROW] : []), + remoteReady: () => { + remote!.begin() + remote!.write({ type: `insert`, value: REMOTE_ROW }) + remote!.commit() + remote!.markReady() + }, + remoteFail: () => remote!.fail(new Error(REMOTE_FAILURE)), } +} - const cleanupFailures: Array = [] - for (const cleanup of cleanups) { - try { - await cleanup() - } catch (error) { - cleanupFailures.push(error) - } - } - if (hasPrimaryFailure) { - if (cleanupFailures.length) { - throw new AggregateError( - [primaryFailure, ...cleanupFailures], - `Persisted-readiness check and cleanup both failed`, - { cause: primaryFailure }, - ) - } - throw primaryFailure - } - if (cleanupFailures.length === 1) throw cleanupFailures[0] - if (cleanupFailures.length > 1) { - throw new AggregateError( - cleanupFailures, - `Persisted-readiness cleanup failed`, - ) +function observe(collection: ReturnType[`collection`]) { + return { + status: collection.status, + error: collection.errors.error?.message ?? null, + rows: + collection.status === `ready` + ? collection.toArray.map((row) => row.id).sort() + : [], } } -/** - * # When may a query render its restored data? - * - * The independent status rule is an all-source conjunction: a query is persisted-ready - * only after every eager persisted source opted in and successfully completed - * its restore in the current sync run. A source that opts out makes the - * query unavailable for this signal. A failed restore reports that exact - * failure. Neither an empty persisted database nor a pending network source - * changes the rule. Initial rendering prefers upstream-signaled Collection - * readiness until the longest opted-in source deadline, then permits only - * completed restoration. - * Network failure permits earlier fallback without stopping sync. A failed - * client query stream is a network failure; a derived-query failure is not. - * If the stream failure precedes restore, the fallback must include the - * restored query rows. An empty restore is complete data, not missing data. - * The production driver holds each adapter's persisted read and - * observes the real persisted wrapper, Collection, live query, and observer. - * - * Checkpoints are before persisted reads settle and after each controlled - * settlement. The row assertion prevents a status-only false green; the empty - * case prevents inferring readiness from row count. The SSR handoff and - * framework scheduling paths have separate focused witnesses below and in - * their packages. This owner does not claim native SQLite, replacement client - * records during the wait, or arbitrary provider/network histories. - * - * Audit: ORC-001/002 use the approved all-source rule and the independent - * expectedPersistedSnapshot model. ORC-003/005 are the finite outcome grammar, - * controlled persistence driver, and public snapshot checks. ORC-004/007/008 - * do not apply: the 36-case product is enumerated, not generated or stateful. - * ORC-006: the pre-implementation observer lacked persistedStatus and - * preloadForInitialRender; the deadline test also failed against the immediate - * persisted-ready race. Before the early-error repair, the held-read witness - * resolved initial rendering with an empty live query despite a durable row. - * Before the client-stream repair, the four stream-failure cases rejected the - * initial-render wait instead of exposing the completed persisted result. - * A preexisting failed stream also left a fresh observer waiting for the - * deadline after restore. The observer-start witness rejects that design. - * An any-one-ready answer fails the joined half-ready checkpoint. A - * nonempty-rows answer fails the empty-restore checkpoint. The combined - * derived-query/client-stream failure rejects false local fallback. ORC-009 maps model - * outcome `ready` to completed persisted restore; it is not Collection status. - * ORC-010 preserves a primary mismatch and distinct cleanup failures through - * checkWithCleanup. ORC-011 has no named shared-fault hypothesis requiring - * another model formulation. ORC-012 records the bounded contract, driver, - * wrong-answer checks, and uncovered histories here and in the coverage map. - * It does not claim all network histories. - * ORC-013: the joined half-ready snapshot rejects any-one-ready; the empty - * restore rejects row-count readiness; the 99/100-ms deadline and 50/100-ms - * joined deadlines reject early fallback; the restart witness rejects stale - * public rows while the replacement read is held. ORC-014 does not apply to a - * real-provider claim: this oracle explicitly ends at its controlled adapter. - */ -describe(`persisted-readiness oracle`, () => { - it(`preserves a failed checkpoint and a secondary cleanup failure`, async () => { - const mismatch = new Error(`wrong persisted status`) - const cleanupFailure = new Error(`cleanup failed`) - await expect( - checkWithCleanup(() => { - throw mismatch - }, [ - () => { - throw cleanupFailure - }, - ]), - ).rejects.toMatchObject({ - cause: mismatch, - errors: [mismatch, cleanupFailure], - }) - }) +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.useRealTimers() +}) - it(`refines the finite two-source readiness model`, async () => { - const outcomes: ReadonlyArray = [`loading`, `ready`, `error`] - let caseId = 0 - // Grammar: each source independently opts in or out and has one of the - // three local outcomes. No network transition is needed to judge this - // observer-level projection; real persistence histories are below. - for (const leftOptedIn of [false, true]) { - for (const rightOptedIn of [false, true]) { - for (const leftOutcome of outcomes) { - for (const rightOutcome of outcomes) { - const id = caseId++ - const leftError = new Error(`left-${id}`) - const rightError = new Error(`right-${id}`) - const left = createCollection({ - id: `local-model-left-${id}`, - getKey: (row) => row.id, - sync: { sync: () => ({}) }, - }) - const right = createCollection({ - id: `local-model-right-${id}`, - getKey: (row) => row.id, - sync: { sync: () => ({}) }, - }) - if (leftOptedIn) { - Object.defineProperty(left.config, PERSISTED_READINESS, { - value: { - networkTimeoutMs: 0, - getOrStartNetworkDeadline: () => Date.now(), - getSnapshot: () => - leftOutcome === `error` - ? { status: `error`, error: leftError } - : { status: leftOutcome }, - subscribe: () => () => {}, - }, - }) - } - if (rightOptedIn) { - Object.defineProperty(right.config, PERSISTED_READINESS, { - value: { - networkTimeoutMs: 0, - getOrStartNetworkDeadline: () => Date.now(), - getSnapshot: () => - rightOutcome === `error` - ? { status: `error`, error: rightError } - : { status: rightOutcome }, - subscribe: () => () => {}, - }, - }) - } - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ left }) - .join({ right }, ({ left: a, right: b }) => eq(a.id, b.id)), - }) - const observer = createLiveQueryObserver(query) - await checkWithCleanup(() => { - const expected = expectedPersistedSnapshot([ - { - optedIn: leftOptedIn, - outcome: leftOutcome, - error: leftError, - }, - { - optedIn: rightOptedIn, - outcome: rightOutcome, - error: rightError, - }, - ]) - expect(observer.getSnapshot(), `case ${id}`).toMatchObject({ - persistedStatus: expected.status, - isPersistedReady: expected.status === `ready`, - persistedError: expected.error, - }) - }, [ - () => observer.dispose(), - () => query.cleanup(), - () => left.cleanup(), - () => right.cleanup(), - ]) - } +describe(`persisted readiness oracle`, () => { + it.each(histories.map((events) => [describe_(events), events] as const))( + `%s`, + async (_name, events) => { + const source = networkFirst(`network-first-${describe_(events)}`) + const live = createLiveQueryCollection((q) => + q.from({ row: source.collection }), + ) + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + try { + void source.collection.preload().catch(() => undefined) + void live.preload().catch(() => undefined) + await vi.advanceTimersByTimeAsync(0) + const model = expected(events) + for (const [index, event] of events.entries()) { + if (event.kind === `restore`) source.restore(event.outcome) + else if (event.kind === `deadline`) + await vi.advanceTimersByTimeAsync(TIMEOUT) + else if (event.outcome === `ready`) source.remoteReady() + else source.remoteFail() + await vi.advanceTimersByTimeAsync(0) + const label = `after ${describe_(events.slice(0, index + 1))}` + expect(observe(source.collection), label).toEqual(model[index]) + // A live query over the Collection shows the same status, by + // live-query readiness, until the Collection ends its first load. + if (model[index]!.status !== `crashed`) + expect(live.status, `${label}: live query`).toBe( + model[index]!.status, + ) } + } finally { + consoleError.mockRestore() + await live.cleanup() + await source.collection.cleanup({ dependents: true }) } - } - expect(caseId).toBe(36) - }) - - for (const rows of [[], [{ id: `one`, value: `restored` }]]) { - it(`waits for ${rows.length ? `nonempty` : `empty`} local restore without waiting for upstream`, async () => { - const fixture = recordingAdapter(rows) - const persisted = source(`local-${rows.length}`, fixture.adapter, true) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const observer = createLiveQueryObserver(query) - const observedPersistedStatuses: Array = [] - const unsubscribe = observer.subscribe(() => { - observedPersistedStatuses.push(observer.getSnapshot().persistedStatus) - }) - await checkWithCleanup(async () => { - expect(observer.getSnapshot().persistedStatus).toBe(`loading`) - expect(observer.getSnapshot().isPersistedReady).toBe(false) - - fixture.load.resolve() - await vi.waitFor(() => { - const snapshot = observer.getSnapshot() - expect(snapshot.persistedStatus).toBe(`ready`) - expect(snapshot.isPersistedReady).toBe(true) - expect(snapshot.status).toBe(`loading`) - expect(snapshot.data).toMatchObject(rows) - }) - await observer.preloadForInitialRender() - expect(observer.getSnapshot().data).toMatchObject(rows) - expect(observedPersistedStatuses).toContain(`ready`) - }, [ - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) - }) - } - - it(`requires every joined source to opt in and finish its own restore`, async () => { - const left = recordingAdapter([{ id: `one`, value: `left` }]) - const right = recordingAdapter([{ id: `one`, value: `right` }]) - const sourceA = source(`local-join-left`, left.adapter, true) - const sourceB = source(`local-join-right`, right.adapter, true) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ a: sourceA }) - .join({ b: sourceB }, ({ a, b }) => eq(a.id, b.id)), - }) - const observer = createLiveQueryObserver(query) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - const initialRenderWait = observer.preloadForInitialRender() - left.load.resolve() - await vi.waitFor(() => expect(sourceA.has(`one`)).toBe(true)) - expect(observer.getSnapshot().persistedStatus).toBe(`loading`) - - right.load.resolve() - await initialRenderWait - expect(observer.getSnapshot().persistedStatus).toBe(`ready`) - expect(observer.getSnapshot().data).toHaveLength(1) - }, [ - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => sourceA.cleanup(), - () => sourceB.cleanup(), - ]) - }) - - it(`follows a live-query source to its underlying persisted Collection`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - const persisted = source(`local-nested`, fixture.adapter, true) - const inner = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const outer = createLiveQueryCollection({ - query: (q) => q.from({ row: inner }), - }) - const observer = createLiveQueryObserver(outer) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - expect(observer.getSnapshot().persistedStatus).toBe(`loading`) - fixture.load.resolve() - await vi.waitFor(() => { - expect(observer.getSnapshot().persistedStatus).toBe(`ready`) - expect(observer.getSnapshot().data).toMatchObject([ - { id: `one`, value: `local` }, - ]) - }) - }, [ - unsubscribe, - () => observer.dispose(), - () => outer.cleanup(), - () => inner.cleanup(), - () => persisted.cleanup(), - ]) - }) + }, + ) - it(`does not claim persisted readiness when one query source did not opt in`, async () => { - const left = recordingAdapter([{ id: `one`, value: `left` }]) - const right = recordingAdapter([{ id: `one`, value: `right` }]) - const sourceA = source(`local-mixed-left`, left.adapter, true) - const sourceB = source(`local-mixed-right`, right.adapter, false) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ a: sourceA }) - .join({ b: sourceB }, ({ a, b }) => eq(a.id, b.id)), - }) - const observer = createLiveQueryObserver(query) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - left.load.resolve() - right.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().data).toHaveLength(1), - ) - expect(observer.getSnapshot().persistedStatus).toBe(`unavailable`) - expect(observer.getSnapshot().isPersistedReady).toBe(false) - }, [ - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => sourceA.cleanup(), - () => sourceB.cleanup(), - ]) + it(`keeps a Collection without initialRender on the network boundary`, async () => { + const source = networkFirst(`no-initial-render`, false) + try { + void source.collection.preload().catch(() => undefined) + source.restore(`rows`) + await vi.advanceTimersByTimeAsync(TIMEOUT * 10) + expect(source.collection.status).toBe(`loading`) + source.remoteReady() + await vi.advanceTimersByTimeAsync(0) + expect(source.collection.status).toBe(`ready`) + } finally { + await source.collection.cleanup({ dependents: true }) + } }) - it(`reports the exact persisted restore failure without claiming readiness`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `restored` }]) - const persisted = source(`local-failure`, fixture.adapter, true) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const observer = createLiveQueryObserver(query) - const unsubscribe = observer.subscribe(() => {}) - const failure = new Error(`local read failed`) - await checkWithCleanup(async () => { - fixture.load.reject(failure) - await vi.waitFor(() => { - const snapshot = observer.getSnapshot() - expect(snapshot.persistedStatus).toBe(`error`) - expect(snapshot.persistedError).toBe(failure) - expect(snapshot.isPersistedReady).toBe(false) - }) - }, [ - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) + it(`defaults the network-first deadline to three seconds`, async () => { + const fixture = controlledAdapter() + const collection = createCollection( + persistedCollectionOptions({ + id: `default-network-deadline`, + getKey: (row) => row.id, + persistence: { adapter: fixture.adapter }, + initialRender: { strategy: `network-first` }, + sync: { sync: () => {} }, + }), + ) + try { + void collection.preload().catch(() => undefined) + fixture.load.resolve([DISK_ROW]) + await vi.advanceTimersByTimeAsync(2_999) + expect(collection.status).toBe(`loading`) + await vi.advanceTimersByTimeAsync(1) + expect(collection.status).toBe(`ready`) + } finally { + await collection.cleanup({ dependents: true }) + } }) it(`rejects network-first initial rendering for on-demand persistence`, () => { - const fixture = recordingAdapter([]) + const fixture = controlledAdapter() expect(() => persistedCollectionOptions({ id: `local-on-demand`, getKey: (row) => row.id, syncMode: `on-demand`, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 100, - }, + initialRender: { strategy: `network-first`, networkTimeoutMs: 100 }, persistence: { adapter: fixture.adapter }, }), ).toThrow(/network-first.*eager/i) }) - it(`defaults the network-first fallback deadline to three seconds`, () => { - const fixture = recordingAdapter([]) - const config = persistedCollectionOptions({ - id: `default-network-deadline`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { strategy: `network-first` }, - }) - expect(getPersistedReadinessSource(config)?.networkTimeoutMs).toBe(3_000) - }) - it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])( `rejects an invalid network timeout (%s)`, (networkTimeoutMs) => { - const fixture = recordingAdapter([]) + const fixture = controlledAdapter() expect(() => persistedCollectionOptions({ id: `invalid-network-deadline`, @@ -487,809 +367,12 @@ describe(`persisted-readiness oracle`, () => { ).toThrow(/networkTimeoutMs/) }, ) +}) - it(`settles a local-only persisted Collection after its eager restore`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - const persisted = createCollection( - persistedCollectionOptions({ - id: `local-only-ready`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 0, - }, - }), - ) - const observer = createLiveQueryObserver(persisted) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - expect(observer.getSnapshot().persistedStatus).toBe(`loading`) - fixture.load.resolve() - await vi.waitFor(() => { - expect(observer.getSnapshot().persistedStatus).toBe(`ready`) - expect(observer.getSnapshot().data).toMatchObject([ - { id: `one`, value: `local` }, - ]) - }) - }, [unsubscribe, () => observer.dispose(), () => persisted.cleanup()]) - }) - - it(`does not mistake an SSR seed for local restore and hands off to local rows`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - const persisted = source(`local-ssr`, fixture.adapter, true) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const client = new DbClient() - client.hydrate({ - collections: [], - liveQueries: [ - { - queryHash: `local-ssr-query`, - dehydratedAt: 1, - snapshot: { - rows: [{ key: `stale`, value: { id: `stale`, value: `server` } }], - }, - }, - ], - }) - const observer = createLiveQueryObserver(query, { - client, - queryHash: `local-ssr-query`, - mode: `wholesale`, - }) - let unsubscribe: (() => void) | undefined - await checkWithCleanup(async () => { - expect(observer.getSnapshot()).toMatchObject({ - status: `ready`, - persistedStatus: `loading`, - isPersistedReady: false, - data: [{ id: `stale`, value: `server` }], - }) - unsubscribe = observer.subscribe(() => {}) - fixture.load.resolve() - await vi.waitFor(() => { - const snapshot = observer.getSnapshot() - expect(snapshot.persistedStatus).toBe(`ready`) - expect(snapshot.status).toBe(`loading`) - expect(snapshot.data).toMatchObject([{ id: `one`, value: `local` }]) - }) - }, [ - () => unsubscribe?.(), - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) - }) - - it(`does not publish a stale restore after cleanup and restart`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - fixture.load.resolve() - const firstRead = deferred() - const secondRead = deferred() - const loadResumeSnapshot = fixture.adapter.loadResumeSnapshot - let baselineReads = 0 - let firstReadReturned = false - // The obsolete sync run returns a distinct row, so a leaked publication - // remains observable even after the replacement restore completes. - fixture.adapter.loadResumeSnapshot = async (id, options) => { - if (options?.includeRows !== false) { - const read = ++baselineReads - await (read === 1 ? firstRead.promise : secondRead.promise) - const snapshot = await loadResumeSnapshot(id, options) - if (read === 1) { - firstReadReturned = true - return { - ...snapshot, - rows: [{ key: `stale`, value: { id: `stale`, value: `obsolete` } }], - } - } - return snapshot - } - return loadResumeSnapshot(id, options) - } - const persisted = source(`local-restart`, fixture.adapter, true) - const observer = createLiveQueryObserver(persisted) - const observedData: Array = [] - const unsubscribe = observer.subscribe(() => { - observedData.push(observer.getSnapshot().data) - }) - await checkWithCleanup(async () => { - await vi.waitFor(() => expect(baselineReads).toBe(1)) - await persisted.cleanup() - persisted.startSyncImmediate() - firstRead.resolve() - await vi.waitFor(() => expect(baselineReads).toBe(2)) - await vi.waitFor(() => expect(firstReadReturned).toBe(true)) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(observer.getSnapshot().persistedStatus).toBe(`loading`) - expect(observer.getSnapshot().data).toEqual([]) - - secondRead.resolve() - await vi.waitFor(() => { - expect(observer.getSnapshot().persistedStatus).toBe(`ready`) - expect(persisted.has(`one`)).toBe(true) - }) - expect(observedData).not.toContainEqual( - expect.arrayContaining([expect.objectContaining({ id: `stale` })]), - ) - }, [ - () => firstRead.resolve(), - () => secondRead.resolve(), - unsubscribe, - () => observer.dispose(), - () => persisted.cleanup(), - ]) - }) - - // The finite conjunction above has no transitions. This legal history gives - // source A two sync runs while source B completes once. A's old restore cannot - // satisfy the current-run conjunct after cleanup. The model needs only each - // source's current restore outcome; it does not copy the wrapper's generations. - // A fresh live query is created after restart because cleanup terminates the - // old query. Its public snapshot and initial-render wait are the checkpoints. - it(`waits for a restarted source after the other joined source restores`, async () => { - const left = recordingAdapter([{ id: `one`, value: `left` }]) - const right = recordingAdapter([{ id: `one`, value: `right` }]) - left.load.resolve() - const secondLeftRead = deferred() - const loadResumeSnapshot = left.adapter.loadResumeSnapshot - let leftReads = 0 - left.adapter.loadResumeSnapshot = async (id, options) => { - if (options?.includeRows !== false && ++leftReads === 2) { - await secondLeftRead.promise - } - return loadResumeSnapshot(id, options) - } - const sourceA = source(`joined-restart-left`, left.adapter, true) - const sourceB = source(`joined-restart-right`, right.adapter, true) - let query: ReturnType | undefined - let observer: ReturnType | undefined - let unsubscribe: (() => void) | undefined - await checkWithCleanup(async () => { - sourceA.startSyncImmediate() - sourceB.startSyncImmediate() - await vi.waitFor(() => expect(leftReads).toBe(1)) - await vi.waitFor(() => expect(sourceA.has(`one`)).toBe(true)) - - await sourceA.cleanup() - sourceA.startSyncImmediate() - await vi.waitFor(() => expect(leftReads).toBe(2)) - right.load.resolve() - await vi.waitFor(() => expect(sourceB.has(`one`)).toBe(true)) - - query = createLiveQueryCollection({ - query: (q) => - q - .from({ a: sourceA }) - .join({ b: sourceB }, ({ a, b }) => eq(a.id, b.id)), - }) - observer = createLiveQueryObserver(query) - unsubscribe = observer.subscribe(() => {}) - const model = [ - { optedIn: true, outcome: `loading` as ModelOutcome, error: undefined }, - { optedIn: true, outcome: `ready` as ModelOutcome, error: undefined }, - ] - expect(observer.getSnapshot().persistedStatus).toBe( - expectedPersistedSnapshot(model).status, - ) - let initialRenderSettled = false - const initialRenderWait = observer.preloadForInitialRender().then(() => { - initialRenderSettled = true - }) - await Promise.resolve() - expect(initialRenderSettled).toBe(false) - - secondLeftRead.resolve() - model[0]!.outcome = `ready` - await initialRenderWait - expect(observer.getSnapshot()).toMatchObject({ - persistedStatus: expectedPersistedSnapshot(model).status, - data: [ - { a: { id: `one`, value: `left` }, b: { id: `one`, value: `right` } }, - ], - }) - }, [ - () => secondLeftRead.resolve(), - () => unsubscribe?.(), - () => observer?.dispose(), - () => query?.cleanup(), - () => sourceA.cleanup(), - () => sourceB.cleanup(), - ]) - }) - - it(`offers an initial-render fallback without changing preload's Collection readiness boundary`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - const persisted = source(`local-suspense`, fixture.adapter, true) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const observer = createLiveQueryObserver(query) - let collectionPreloadSettled = false - await checkWithCleanup(async () => { - const collectionPreload = observer.preload() - void collectionPreload.then( - () => (collectionPreloadSettled = true), - () => (collectionPreloadSettled = true), - ) - const initialRenderWait = observer.preloadForInitialRender() - fixture.load.resolve() - await initialRenderWait - expect(observer.getSnapshot()).toMatchObject({ - status: `loading`, - persistedStatus: `ready`, - data: [{ id: `one`, value: `local` }], - }) - expect(collectionPreloadSettled).toBe(false) - }, [ - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) - }) - - it(`keeps the initial render gated until the network-first deadline`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - let markCollectionReady!: () => void - const persisted = createCollection( - persistedCollectionOptions({ - id: `network-first-deadline`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 100, - }, - sync: { - sync: ({ markReady }) => { - markCollectionReady = markReady - return {} - }, - }, - }), - ) - const observer = createLiveQueryObserver(persisted) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - fixture.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().persistedStatus).toBe(`ready`), - ) - - vi.useFakeTimers() - try { - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - await vi.advanceTimersByTimeAsync(99) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await initialRender - expect(settled).toBe(true) - expect(observer.getSnapshot().data).toMatchObject([ - { id: `one`, value: `persisted` }, - ]) - expect(persisted.status).toBe(`loading`) - } finally { - vi.useRealTimers() - } - markCollectionReady() - await vi.waitFor(() => expect(persisted.status).toBe(`ready`)) - }, [unsubscribe, () => observer.dispose(), () => persisted.cleanup()]) - }) - - it(`uses Collection readiness before the fallback deadline`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - let markCollectionReady!: () => void - const persisted = createCollection( - persistedCollectionOptions({ - id: `network-first-success`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 1_000, - }, - sync: { - sync: ({ markReady }) => { - markCollectionReady = markReady - return {} - }, - }, - }), - ) - const observer = createLiveQueryObserver(persisted) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - fixture.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().persistedStatus).toBe(`ready`), - ) - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - markCollectionReady() - await initialRender - expect(settled).toBe(true) - expect(persisted.status).toBe(`ready`) - }, [unsubscribe, () => observer.dispose(), () => persisted.cleanup()]) - }) - - it(`falls back to persisted data as soon as the network fails`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - const networkFailure = new Error(`network failed`) - let markNetworkError!: (error: unknown) => void - const persisted = createCollection( - persistedCollectionOptions({ - id: `network-first-failure`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 1_000, - }, - sync: { - sync: ({ fail }) => { - markNetworkError = fail - return {} - }, - }, - }), - ) - const observer = createLiveQueryObserver(persisted) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - fixture.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().persistedStatus).toBe(`ready`), - ) - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - markNetworkError(networkFailure) - await initialRender - expect(settled).toBe(true) - expect(persisted.status).toBe(`error`) - expect(observer.getSnapshot().data).toMatchObject([ - { id: `one`, value: `persisted` }, - ]) - }, [unsubscribe, () => observer.dispose(), () => persisted.cleanup()]) - }) - - for (const rows of [[], [{ id: `one`, value: `persisted` }]]) { - for (const failureBeforeRestore of [false, true]) { - it(`uses a ${rows.length ? `nonempty` : `empty`} restore after a client stream fails ${failureBeforeRestore ? `before` : `after`} restore`, async () => { - const fixture = recordingAdapter(rows) - const persisted = source( - `stream-failure-${rows.length}-${failureBeforeRestore}`, - fixture.adapter, - true, - 60_000, - ) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const client = new DbClient() - const queryHash = `stream-failure-${rows.length}-${failureBeforeRestore}` - const stream = deferred<{ rows: Array }>() - void client - ._registerLiveQuery(queryHash, stream.promise) - .catch(() => {}) - const observer = createLiveQueryObserver(query, { - client, - queryHash, - mode: `wholesale`, - }) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - let settlement: `pending` | `resolved` | `rejected` = `pending` - const initialRender = observer.preloadForInitialRender() - void initialRender.then( - () => (settlement = `resolved`), - () => (settlement = `rejected`), - ) - const rejectStream = async () => { - stream.reject(new Error(`client stream failed`)) - await stream.promise.catch(() => {}) - } - - if (failureBeforeRestore) { - await rejectStream() - expect(settlement).toBe(`pending`) - fixture.load.resolve() - } else { - fixture.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().persistedStatus).toBe(`ready`), - ) - expect(settlement).toBe(`pending`) - await rejectStream() - } - - await initialRender - expect(settlement).toBe(`resolved`) - expect(observer.isInitialRenderReady()).toBe(true) - expect(observer.getSnapshot().data).toMatchObject(rows) - }, [ - () => fixture.load.resolve(), - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) - }) - } - } - - it(`uses a completed restore when the client stream failed before the observer started`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - const persisted = source( - `preexisting-stream-failure`, - fixture.adapter, - true, - 60_000, - ) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const client = new DbClient() - const queryHash = `preexisting-stream-failure` - const stream = deferred<{ rows: Array }>() - const registered = client._registerLiveQuery(queryHash, stream.promise) - stream.reject(new Error(`client stream failed`)) - await expect(registered).rejects.toThrow(`client stream failed`) - - const observer = createLiveQueryObserver(query, { - client, - queryHash, - mode: `wholesale`, - }) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then( - () => { - settled = true - }, - () => {}, - ) - fixture.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().persistedStatus).toBe(`ready`), - ) - await vi.waitFor(() => expect(settled).toBe(true), { timeout: 1_000 }) - await initialRender - expect(observer.getSnapshot().data).toMatchObject([ - { id: `one`, value: `persisted` }, - ]) - }, [ - () => fixture.load.resolve(), - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) - }) - - for (const failureMode of [`fail`, `throw`] as const) { - it(`publishes restored query rows when ${failureMode} precedes the persisted read`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - const networkFailure = new Error(`network failed before restore`) - let markNetworkError!: (error: unknown) => void - let networkSyncStarted = false - const persisted = createCollection( - persistedCollectionOptions({ - id: `network-failure-before-restore`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 1_000, - }, - sync: { - sync: ({ fail }) => { - networkSyncStarted = true - if (failureMode === `throw`) throw networkFailure - markNetworkError = fail - return {} - }, - }, - }), - ) - const query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - const observer = createLiveQueryObserver(query) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - const initialRenderWait = observer.preloadForInitialRender() - await vi.waitFor(() => expect(networkSyncStarted).toBe(true)) - if (failureMode === `fail`) markNetworkError(networkFailure) - expect(observer.getSnapshot()).toMatchObject({ - persistedStatus: `loading`, - data: [], - }) - - fixture.load.resolve() - await initialRenderWait - expect(persisted.has(`one`)).toBe(true) - expect(observer.getSnapshot()).toMatchObject({ - status: `error`, - persistedStatus: `ready`, - data: [{ id: `one`, value: `persisted` }], - }) - }, [ - () => fixture.load.resolve(), - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => persisted.cleanup(), - ]) - }) - } - - it(`does not hide a query failure behind an available persisted source`, async () => { - const persisted = createCollection({ - id: `query-failure-is-not-network-failure`, - getKey: (row) => row.id, - sync: { sync: () => ({}) }, - }) - Object.defineProperty(persisted.config, PERSISTED_READINESS, { - value: { - networkTimeoutMs: 60_000, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: `ready` }), - subscribe: () => () => {}, - }, - }) - const failure = new Error(`query failed independently of its source`) - const preload = vi.spyOn(persisted, `preload`).mockRejectedValue(failure) - const observer = createLiveQueryObserver(persisted) - await checkWithCleanup(async () => { - await expect(observer.preloadForInitialRender()).rejects.toBe(failure) - expect(observer.isInitialRenderReady()).toBe(false) - }, [ - () => observer.dispose(), - () => preload.mockRestore(), - () => persisted.cleanup(), - ]) - }) - - it(`does not use persisted fallback when a derived query and client stream both fail`, async () => { - const queryFailure = new Error(`derived query failed`) - const streamFailure = new Error(`client stream failed`) - const sourceCollection = createCollection({ - id: `query-and-stream-failure-source`, - getKey: (row) => row.id, - syncMode: `on-demand`, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: () => { - throw queryFailure - }, - } - }, - }, - }) - Object.defineProperty(sourceCollection.config, PERSISTED_READINESS, { - value: { - networkTimeoutMs: 60_000, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: `ready` }), - subscribe: () => () => {}, - }, - }) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ row: sourceCollection }) - .where(({ row }) => eq(row.value, `persisted`)), - }) - const client = new DbClient() - const queryHash = `query-and-stream-failure` - const stream = deferred<{ rows: Array }>() - const observer = createLiveQueryObserver(query, { - client, - queryHash, - mode: `wholesale`, - }) - await checkWithCleanup(async () => { - await expect(query.preload()).rejects.toBe(queryFailure) - expect(sourceCollection.status).toBe(`ready`) - expect(query.status).toBe(`error`) - - void client._registerLiveQuery(queryHash, stream.promise).catch(() => {}) - const initialRender = observer.preloadForInitialRender() - stream.reject(streamFailure) - await expect(initialRender).rejects.toBe(streamFailure) - expect(observer.isInitialRenderReady()).toBe(false) - }, [ - () => stream.reject(streamFailure), - () => observer.dispose(), - () => query.cleanup(), - () => sourceCollection.cleanup(), - ]) - }) - - it(`uses the longest network deadline for a joined query`, async () => { - const left = recordingAdapter([{ id: `one`, value: `left` }]) - const right = recordingAdapter([{ id: `one`, value: `right` }]) - const sourceA = source(`deadline-left`, left.adapter, true, 50) - const sourceB = source(`deadline-right`, right.adapter, true, 100) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ a: sourceA }) - .join({ b: sourceB }, ({ a, b }) => eq(a.id, b.id)), - }) - const observer = createLiveQueryObserver(query) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - left.load.resolve() - right.load.resolve() - await vi.waitFor(() => - expect(observer.getSnapshot().persistedStatus).toBe(`ready`), - ) - vi.useFakeTimers() - try { - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then(() => { - settled = true - }) - await vi.advanceTimersByTimeAsync(50) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(50) - await initialRender - expect(observer.getSnapshot().data).toHaveLength(1) - } finally { - vi.useRealTimers() - } - }, [ - unsubscribe, - () => observer.dispose(), - () => query.cleanup(), - () => sourceA.cleanup(), - () => sourceB.cleanup(), - ]) - }) - - it(`waits for restore after the deadline instead of rendering incomplete data`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - const persisted = source( - `late-persisted-restore`, - fixture.adapter, - true, - 100, - ) - const observer = createLiveQueryObserver(persisted) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - vi.useFakeTimers() - try { - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then(() => { - settled = true - }) - await vi.advanceTimersByTimeAsync(100) - expect(settled).toBe(false) - expect(observer.getSnapshot().persistedStatus).toBe(`loading`) - - fixture.load.resolve() - await initialRender - expect(observer.getSnapshot()).toMatchObject({ - persistedStatus: `ready`, - data: [{ id: `one`, value: `persisted` }], - }) - } finally { - vi.useRealTimers() - } - }, [ - () => fixture.load.resolve(), - unsubscribe, - () => observer.dispose(), - () => persisted.cleanup(), - ]) - }) - - it(`rejects an initial-render wait and clears its deadline on disposal`, async () => { - const fixture = recordingAdapter([]) - const persisted = source( - `disposed-initial-render`, - fixture.adapter, - true, - 100, +function describe_(events: ReadonlyArray): string { + return events + .map((event) => + event.kind === `deadline` ? `deadline` : `${event.kind}:${event.outcome}`, ) - const observer = createLiveQueryObserver(persisted) - await checkWithCleanup(async () => { - vi.useFakeTimers() - try { - const initialRender = observer.preloadForInitialRender() - const rejected = expect(initialRender).rejects.toThrow(/disposed/i) - expect(vi.getTimerCount()).toBeGreaterThan(0) - observer.dispose() - await rejected - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }, [ - () => fixture.load.resolve(), - () => observer.dispose(), - () => persisted.cleanup(), - ]) - }) - - it(`keeps the network preload boundary for an unconfigured source`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `persisted` }]) - const persisted = source(`unconfigured-network`, fixture.adapter, false) - const observer = createLiveQueryObserver(persisted) - const unsubscribe = observer.subscribe(() => {}) - await checkWithCleanup(async () => { - fixture.load.resolve() - await vi.waitFor(() => expect(persisted.has(`one`)).toBe(true)) - let settled = false - const initialRender = observer.preloadForInitialRender() - void initialRender.then( - () => { - settled = true - }, - () => {}, - ) - await Promise.resolve() - expect(observer.getSnapshot().persistedStatus).toBe(`unavailable`) - expect(settled).toBe(false) - }, [unsubscribe, () => observer.dispose(), () => persisted.cleanup()]) - }) - - it(`waits for a new query to materialize an already restored source`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - const persisted = source(`local-already-restored`, fixture.adapter, true) - const sourceObserver = createLiveQueryObserver(persisted) - const unsubscribeSource = sourceObserver.subscribe(() => {}) - let query: ReturnType | undefined - let queryObserver: ReturnType | undefined - await checkWithCleanup(async () => { - fixture.load.resolve() - await vi.waitFor(() => - expect(sourceObserver.getSnapshot().persistedStatus).toBe(`ready`), - ) - query = createLiveQueryCollection({ - query: (q) => q.from({ row: persisted }), - }) - queryObserver = createLiveQueryObserver(query) - await queryObserver.preloadForInitialRender() - expect(queryObserver.getSnapshot()).toMatchObject({ - persistedStatus: `ready`, - data: [{ id: `one`, value: `local` }], - }) - }, [ - () => queryObserver?.dispose(), - () => query?.cleanup(), - unsubscribeSource, - () => sourceObserver.dispose(), - () => persisted.cleanup(), - ]) - }) -}) + .join(` > `) +} diff --git a/packages/db/skills/db-core/collection-setup/references/query-adapter.md b/packages/db/skills/db-core/collection-setup/references/query-adapter.md index f0cef1b3f0..b686299dce 100644 --- a/packages/db/skills/db-core/collection-setup/references/query-adapter.md +++ b/packages/db/skills/db-core/collection-setup/references/query-adapter.md @@ -240,7 +240,7 @@ same exact key. Exact-key and prefix invalidation refetch active eager and on-demand queries, then rematerialize their results. Overlapping subsets keep rows that another active subset still owns. A failed refetch retains current rows and records the -error as a health error in `collection.health`. Cleaned-up or otherwise inactive queries +error as the source node's `failureReason` in `collection.errors`. Cleaned-up or otherwise inactive queries do not rematerialize. ## Predicate Push-Down (syncMode: "on-demand") diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index 4dabaf1bb6..04c23a909f 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -2,7 +2,7 @@ name: db-core/custom-adapter description: > Building custom collection adapters for new backends. SyncConfig interface: - sync function receiving begin, write, commit, markReady, markError, + sync function receiving begin, write, commit, markReady, fail, reportError, reportHealthy, truncate, metadata primitives and returning cleanup, loadSubset, and optional unloadSubset handlers. @@ -55,7 +55,7 @@ function myBackendCollectionOptions(config: { return { getKey: config.getKey, sync: { - sync: ({ begin, write, commit, markReady, markError, collection }) => { + sync: ({ begin, write, commit, markReady, fail, collection }) => { let isInitialSyncComplete = false const bufferedEvents: Array> = [] const initialSyncAbort = new AbortController() @@ -97,7 +97,7 @@ function myBackendCollectionOptions(config: { console.error('Initial sync failed:', error) // Give up only before readiness. After it, report the problem // with reportError() and keep the rows. - if (collection.status === 'loading') markError(error) + if (collection.status === 'loading') fail(error) }) // 5. Return cleanup function @@ -234,7 +234,7 @@ Without persistence the metadata is in-memory only and does not survive reloads. With persistence, it is durable across sessions. ```ts -sync: ({ begin, write, commit, markReady, markError, metadata }) => { +sync: ({ begin, write, commit, markReady, fail, metadata }) => { if (!metadata) throw new Error('Sync metadata API is unavailable') // Row metadata: store per-row state (e.g. server version, ETag) @@ -278,7 +278,7 @@ sync: ({ begin, write, commit, markReady, metadata }) => { }) stream.on('ready', () => markReady()) - stream.on('initial-error', (error) => markError(error)) + stream.on('initial-error', (error) => fail(error)) return () => stream.close() } ``` @@ -348,19 +348,22 @@ sync: ({ begin, write, commit, markReady }) => { `markReady()` transitions the collection to "ready" status. Without it, live queries never resolve and `useLiveSuspenseQuery` hangs forever in Suspense. If initial sync fails before it produces a usable snapshot and you stop -retrying, call `markError(error)` instead. This rejects readiness waits with the -supplied cause and moves dependent live queries to the error state. Calling -`markError()` without a cause remains supported and rejects with a generic -collection-state error. The error is final for the sync run: `markReady()` -afterward throws. Only a restart (`cleanup()`, then `preload()`) starts a new -sync run. +retrying, call `fail(error)` instead. The status becomes `error`, readiness +waits reject with the supplied cause, and dependent live queries move to +`error`. Every later call in the sync run throws, including `markReady()`. +Only `collection.reload()` starts a new sync run. After the first `markReady()`, the rows stay. Report a sync problem with -`reportError(error)` while you keep trying, and call `reportHealthy()` when -sync works again. When you stop trying, call `markError(error)`: after -readiness it is a final health error, and only a restart clears it. Apps read these reports from -`collection.health` and from each live query's `errors` tree. While a retry is -in progress, report nothing. +`reportError(error)` while you keep trying; it sets the node's +`failureReason`. Call `reportHealthy()` when sync works again; it does nothing +when nothing is wrong. When you stop trying, call `fail(error)`: after +readiness the node shows an `error`, and only `reload()` clears it. +`reportError()` and `reportHealthy()` throw before the first `markReady()`. +Apps read these reports from `collection.errors` and from each live query's +`errors` tree. While a retry is in progress before readiness, report nothing. + +A throwing `sync` function crashes the Collection (`status: 'crashed'`). +Reserve that for broken local state, not for network failures. Source: docs/guides/collection-options-creator.md diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index 4816622092..e89916658a 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -83,6 +83,20 @@ export function hasOwnProblem( return failureReason !== null || error !== null } +/** + * Every node in an error tree that shows a problem of its own, in tree order: + * a `failureReason`, an `error`, or include instances with problems. A node + * whose only problems are below it is not listed; its descendants are. + */ +export function findProblems(tree: ErrorTreeNode): Array { + if (!tree.hasErrors) return [] + const ownProblem = + tree.failureReason !== null || + tree.error !== null || + (tree.instances?.withErrors ?? 0) > 0 + return [...(ownProblem ? [tree] : []), ...tree.sources.flatMap(findProblems)] +} + function countInstances( collections: ReadonlyArray>, ): { total: number; withErrors: number } { diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 65b24b4960..a400d6d6c6 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -55,6 +55,7 @@ import type { WithVirtualProps } from '../virtual-props.js' import type { TransactionScope } from '../transactions.js' export type { CollectionIndexMetadata } from './events.js' +export { findProblems } from './error-tree.js' export type CollectionBase = Pick< SortedMap, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a13c679d16..fe3ea2f19a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -17,7 +17,6 @@ export * from './optimistic-action' export * from './live-query-adapter' export * from './live-query-observer' /** @internal First-party persistence adapter capability. */ -export * from './persisted-readiness' export * from './live-query-options' /** @internal Unstable adapter primitive for RFC #1623. */ export * from './live-query-window-controller' diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 66c4c10da4..75cda6cae5 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -6,56 +6,12 @@ import { } from './live-query-adapter.js' import { getBuilderFromConfig } from './query/live/collection-registry.js' import { getPooledLiveQuerySource } from './query/pooled-live-query.js' -import { getPersistedReadinessSource } from './persisted-readiness.js' import type { Collection } from './collection/index.js' import type { DbClient, DehydratedLiveQueryResult } from './client.js' -import type { PersistedReadinessSource } from './persisted-readiness.js' import type { ChangeMessage, CollectionStatus, ErrorTreeNode } from './types.js' -export type LiveQueryPersistedStatus = - `unavailable` | `loading` | `ready` | `error` - -// React may discard an uncommitted Suspense render and construct a new observer -// on retry. The Collection survives that retry; its initial-render preload -// must remain observable without treating source readiness as query readiness. -const INITIAL_RENDER_PRELOADS = new WeakSet>() - -const NO_PERSISTED_READINESS = { - status: `unavailable`, - error: undefined, -} as const const noop = () => {} -interface PersistedSourceEntry { - collection: Collection - readiness: PersistedReadinessSource -} - -function collectPersistedReadinessSources( - root: Collection, -): ReadonlyArray | undefined { - // A few integrations provide Collection-compatible objects without config. - if (!(root as { config?: unknown }).config) return undefined - const seen = new Set>() - const sources: Array = [] - const visit = (collection: Collection): boolean => { - if (seen.has(collection)) return true - seen.add(collection) - // A few integrations provide Collection-compatible objects without config. - const config = (collection as { config?: typeof collection.config }).config - if (!config) return false - const builder = getBuilderFromConfig(config) - if (builder) { - return builder.getSourceCollections().every(visit) - } - const source = getPersistedReadinessSource(config) - if (!source) return false - sources.push({ collection, readiness: source }) - return true - } - return visit(root) && sources.length > 0 ? sources : undefined -} - /** * The canonical, adapter-agnostic view of a live query at a point in time. * @@ -88,10 +44,6 @@ export interface LiveQuerySnapshot< status: CollectionStatus | `disabled` isLoading: boolean isReady: boolean - /** Persisted restore is separate from upstream/Collection readiness. */ - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean @@ -152,10 +104,6 @@ export interface LiveQueryObserver< subscribe: (listener: LiveQueryObserverListener) => () => void /** Resolve once the collection has loaded its first data. */ preload: () => Promise - /** Resolve at network readiness or a permitted persisted fallback. */ - preloadForInitialRender: () => Promise - /** Whether a persisted fallback has already passed the network-first gate. */ - isInitialRenderReady: () => boolean /** The transport or preload error for this query, if it has not produced data. */ getError: () => unknown /** Capture the ordered query result without serializing its source collections. */ @@ -192,9 +140,6 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot = { status: `disabled`, isLoading: false, isReady: true, - persistedStatus: `unavailable`, - isPersistedReady: false, - persistedError: undefined, isIdle: false, isLoadingError: false, isCrashed: false, @@ -212,8 +157,6 @@ class LiveQueryObserverImpl< private readonly wholesale: boolean private readonly client: DbClient | undefined private readonly queryHash: string | undefined - private readonly persistedSources: - ReadonlyArray | undefined private readonly onPreload: (() => void) | undefined private visibleStatus: CollectionStatus | undefined private cachedEntries: Array<[TKey, T]> | undefined @@ -245,9 +188,6 @@ class LiveQueryObserverImpl< private liveResultIsAuthoritative = false private handoffScheduled = false private preloadPromise: Promise | undefined - private initialRenderPromise: Promise | undefined - private initialRenderReady = false - private cancelInitialRenderWait: (() => void) | undefined private disposed = false // Sync activation belongs to the first subscription (attach), so building @@ -265,9 +205,6 @@ class LiveQueryObserverImpl< this.wholesale = wholesale this.client = client this.queryHash = queryHash - this.persistedSources = collection - ? collectPersistedReadinessSources(collection) - : undefined this.onPreload = onPreload this.registerClientResource() } @@ -279,18 +216,6 @@ class LiveQueryObserverImpl< this.syncHydrationState() if (!this.attached) this.refreshDetachedState(collection) - const local = this.readPersistedReadiness() - const persistedStatus = - this.hasHydrationSeed() && local.status === `ready` - ? `loading` - : local.status - if ( - this.cachedSnapshot.persistedStatus !== persistedStatus || - this.cachedSnapshot.persistedError !== local.error - ) { - this.snapshotDirty = true - } - if (this.snapshotDirty) { const entries = this.getVisibleEntries(collection) const data = entries.map(([, value]) => value) @@ -338,9 +263,6 @@ class LiveQueryObserverImpl< layoutRevision: this.layoutRevision, status, ...getLiveQueryStatusFlags(status), - persistedStatus, - isPersistedReady: persistedStatus === `ready`, - persistedError: local.error, isEnabled: true, isSyncError: status === `ready` && errors?.hasErrors === true, errors, @@ -379,29 +301,6 @@ class LiveQueryObserverImpl< return this.hydrationSeed !== undefined && !this.liveResultIsAuthoritative } - private readPersistedReadiness(): { - status: LiveQueryPersistedStatus - error: unknown | undefined - } { - const sources = this.persistedSources - if (!sources) return NO_PERSISTED_READINESS - let loading = false - let error: unknown | undefined - let failed = false - for (const source of sources) { - const snapshot = source.readiness.getSnapshot() - if (snapshot.status === `error` && !failed) { - error = snapshot.error - failed = true - } else if (snapshot.status === `loading`) { - loading = true - } - } - return failed - ? { status: `error`, error } - : { status: loading ? `loading` : `ready`, error: undefined } - } - private getVisibleEntries( collection: Collection, ): Array<[TKey, T]> { @@ -528,8 +427,7 @@ class LiveQueryObserverImpl< !this.attached || !collection || !this.hasHydrationSeed() || - (collection.status !== `ready` && - this.readPersistedReadiness().status !== `ready`) || + collection.status !== `ready` || collection.isLoadingSubset ) { return @@ -798,20 +696,6 @@ class LiveQueryObserverImpl< notify([], collection.status, true), ) : noop - const persistedUnsubs = this.persistedSources?.map((source) => - source.readiness.subscribe(() => { - if (this.disposed || this.subscriptions.size === 0) return - this.snapshotDirty = true - if ( - this.hasHydrationSeed() && - this.readPersistedReadiness().status === `ready` - ) { - this.scheduleHydrationHandoff() - } else { - this.emit(undefined) - } - }), - ) // `subscribeChanges` delivers the initial state synchronously, so a // listener can dispose the observer while the collection subscription is @@ -844,7 +728,6 @@ class LiveQueryObserverImpl< statusUnsub() errorsUnsub() layoutUnsub() - persistedUnsubs?.forEach((unsubscribe) => unsubscribe()) subscription?.unsubscribe() } this.collectionUnsub = release @@ -879,12 +762,7 @@ class LiveQueryObserverImpl< } if (this.hasHydrationSeed()) { if (!this.wholesale) this.seed(Array.from(this.subscriptions)[0]!) - if ( - collection.status === `ready` || - this.readPersistedReadiness().status === `ready` - ) { - this.scheduleHydrationHandoff() - } + if (collection.status === `ready`) this.scheduleHydrationHandoff() } } @@ -1049,151 +927,9 @@ class LiveQueryObserverImpl< return preloadPromise } - isInitialRenderReady(): boolean { - if (this.initialRenderReady) return true - const collection = this.collection - const sources = this.persistedSources - if ( - !collection || - !INITIAL_RENDER_PRELOADS.has(collection) || - !sources || - this.readPersistedReadiness().status !== `ready` - ) { - return false - } - const sourceFailed = sources.some( - (source) => source.collection.status === `error`, - ) - if (collection.status === `error`) return sourceFailed - const clientQuery = - this.client && this.queryHash - ? this.client._getLiveQuery(this.queryHash) - : undefined - if (clientQuery?.status === `error`) return true - return sources.every( - (source) => Date.now() >= source.readiness.getOrStartNetworkDeadline(), - ) - } - - preloadForInitialRender(): Promise { - if (this.disposed) - return Promise.reject(new LiveQueryObserverDisposedError()) - const sources = this.persistedSources - if (!sources) return this.preload() - if (this.initialRenderReady) return Promise.resolve() - if (this.initialRenderPromise) return this.initialRenderPromise - - const network = this.preload() - const clientQuery = - this.client && this.queryHash - ? this.client._getLiveQuery(this.queryHash) - : undefined - const clientStream = - clientQuery?.promise === network ? clientQuery : undefined - if (this.collection) INITIAL_RENDER_PRELOADS.add(this.collection) - - const unsubscribers: Array<() => void> = [] - let cancel!: () => void - let timer: ReturnType | undefined - const initialRender = new Promise((resolve, reject) => { - let networkReady = false - // React can start a new observer after the client stream has failed. - let failedClientStream = - clientQuery?.status === `error` ? clientQuery : undefined - let networkFailed = failedClientStream !== undefined - let networkError: unknown = failedClientStream?.error - let settled = false - const finish = (failure?: { error: unknown }) => { - if (settled) return - settled = true - if (failure) reject(failure.error) - else { - this.initialRenderReady = true - resolve() - } - } - cancel = () => finish({ error: new LiveQueryObserverDisposedError() }) - this.cancelInitialRenderWait = cancel - const check = () => { - if (settled) return - if (timer !== undefined) { - clearTimeout(timer) - timer = undefined - } - if (networkReady) { - finish() - return - } - const persisted = this.readPersistedReadiness() - const networkDeadlineAt = Math.max( - ...sources.map((source) => - source.readiness.getOrStartNetworkDeadline(), - ), - ) - const remainingNetworkMs = Math.max(0, networkDeadlineAt - Date.now()) - const deadlinePassed = remainingNetworkMs === 0 - const sourceFailed = sources.some( - (source) => source.collection.status === `error`, - ) - const clientStreamFailed = - failedClientStream !== undefined && - this.client !== undefined && - this.queryHash !== undefined && - this.client._getLiveQuery(this.queryHash) === failedClientStream - if ( - networkFailed && - !sourceFailed && - (this.collection?.status === `error` || !clientStreamFailed) - ) { - finish({ error: networkError }) - } else if ( - (deadlinePassed || networkFailed) && - persisted.status === `ready` - ) { - finish() - } else if (networkFailed && persisted.status === `error`) { - finish({ error: networkError ?? persisted.error }) - } else if (!deadlinePassed && !networkFailed) { - timer = setTimeout(check, remainingNetworkMs) - } - } - for (const source of sources) { - unsubscribers.push(source.readiness.subscribe(check)) - } - void network.then( - () => { - networkReady = true - check() - }, - (error) => { - networkFailed = true - networkError = error - failedClientStream = - clientStream?.status === `error` ? clientStream : undefined - check() - }, - ) - check() - }) - const promise = initialRender.finally(() => { - if (this.cancelInitialRenderWait === cancel) { - this.cancelInitialRenderWait = undefined - } - unsubscribers.forEach((unsubscribe) => unsubscribe()) - if (timer !== undefined) clearTimeout(timer) - if (this.initialRenderPromise === promise) { - this.initialRenderPromise = undefined - } - }) - this.initialRenderPromise = promise - return promise - } - dispose(): void { if (this.disposed) return this.disposed = true - this.cancelInitialRenderWait?.() - this.cancelInitialRenderWait = undefined this.detach() for (const subRecord of this.subscriptions) subRecord.active = false this.subscriptions.clear() diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index a6fce044e9..1a87961fd6 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -7,10 +7,7 @@ import { createLiveQueryObserver } from './live-query-observer.js' import { BaseQueryBuilder } from './query/builder/index.js' import { deepEquals } from './utils.js' import { codedMessage, devBuild } from './error-message.js' -import type { - LiveQueryObserver, - LiveQuerySnapshot, -} from './live-query-observer.js' +import type { LiveQueryObserver } from './live-query-observer.js' import type { Collection } from './collection/index.js' import type { CollectionStatus, ErrorTreeNode } from './types.js' import type { @@ -524,9 +521,6 @@ export interface LiveQueryWindowSnapshot< status: CollectionStatus | `disabled` isLoading: boolean isReady: boolean - persistedStatus: LiveQuerySnapshot[`persistedStatus`] - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean isLoadingError: boolean isCrashed: boolean @@ -689,9 +683,6 @@ class LiveQueryWindowControllerImpl< status: observerSnapshot.status, isLoading: observerSnapshot.isLoading, isReady: observerSnapshot.isReady, - persistedStatus: observerSnapshot.persistedStatus, - isPersistedReady: observerSnapshot.isPersistedReady, - persistedError: observerSnapshot.persistedError, isIdle: observerSnapshot.isIdle, isLoadingError: observerSnapshot.isLoadingError, isCrashed: observerSnapshot.isCrashed, diff --git a/packages/db/src/persisted-readiness.ts b/packages/db/src/persisted-readiness.ts deleted file mode 100644 index b7bd34c2de..0000000000 --- a/packages/db/src/persisted-readiness.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** The local-restore boundary of one opted-in persisted Collection. */ -export type PersistedReadinessSnapshot = - | { status: `loading`; error?: never } - | { status: `ready`; error?: never } - | { status: `error`; error: unknown } - -/** @internal Capability supplied by a persistence adapter, not by core sync. */ -export interface PersistedReadinessSource { - /** Time to prefer network before a completed persisted restore may render. */ - networkTimeoutMs: number - /** Anchor the deadline to this Collection's current sync run. */ - getOrStartNetworkDeadline: () => number - getSnapshot: () => PersistedReadinessSnapshot - subscribe: (listener: () => void) => () => void -} - -/** @internal Config capability shared with first-party persistence adapters. */ -export const PERSISTED_READINESS = Symbol.for(`@tanstack/db.persistedReadiness`) - -export function getPersistedReadinessSource( - config: object, -): PersistedReadinessSource | undefined { - return (config as { [PERSISTED_READINESS]?: PersistedReadinessSource })[ - PERSISTED_READINESS - ] -} diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 0d97689dfc..7742170200 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1500,8 +1500,8 @@ full acquisition cannot abort work still owned by those narrower acquisitions. ### Pooled live queries A framework adapter may serve a live query from an equality partition instead -of this graph. That applies only when the query reads one eager, -non-persisted source Collection, its `where` has at least one +of this graph. That applies only when the query reads one eager +source Collection, its `where` has at least one `eq(field, literal)` conjunct, every other conjunct reads only that row's own fields, any `orderBy` reads only that row's own fields without a custom string comparator, and it has no other clause, `limit`, `offset`, `DbClient`, or Suspense key. Such a diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 9dd070a9b4..0976283442 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -8,7 +8,6 @@ import { import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' import { makeComparator } from '../utils/comparison.js' import { isVirtualPropName } from '../virtual-props.js' -import { getPersistedReadinessSource } from '../persisted-readiness.js' import { getWhereExpression } from './ir.js' import { equalityConjunct, equalityKey, readPath } from './equality-conjunct.js' import { compileExpression, toBooleanPredicate } from './compiler/evaluators.js' @@ -755,13 +754,8 @@ export function createPooledLiveQuery( const shape = poolableShape(ir) if (!shape || ir.from.type !== `collectionRef`) return undefined const source = ir.from.collection - // Persisted restore and on-demand loading need the live-query Collection. - if ( - source.config.syncMode === `on-demand` || - getPersistedReadinessSource(source.config) - ) { - return undefined - } + // On-demand loading needs the live-query Collection. + if (source.config.syncMode === `on-demand`) return undefined let partitions = partitionsBySource.get(source) if (!partitions) { partitions = new Map() diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 35ddab75b0..6556fa8d1e 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -123,9 +123,6 @@ export interface ConformanceResult { state: ReadonlyMap | undefined status: string isReady: boolean - persistedStatus?: string - isPersistedReady?: boolean - persistedError?: unknown isLoadingError: boolean /** Local storage or an invariant broke: `status` is `crashed`. */ isCrashed: boolean diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index d592bbcb71..39818dfaaa 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -38,12 +38,7 @@ import { expectUnorderedRows, selectedRow, } from './result-laws' -import type { - DeferredSourceHandle, - LiveQueryDriver, - LiveQueryHandle, - Row, -} from './contract' +import type { LiveQueryDriver, LiveQueryHandle, Row } from './contract' const SEED: Array = [ { id: `1`, name: `John Doe`, age: 30, team: `a` }, @@ -100,39 +95,6 @@ export function runSuite(rawDriver: LiveQueryDriver) { mountDisabled: () => track(rawDriver.mountDisabled()), } - const installPersistedReadiness = ( - source: DeferredSourceHandle, - settledSnapshot: - { status: `ready` } | { status: `error`; error: unknown } = { - status: `ready`, - }, - ) => { - let snapshot: - | { status: `loading` } - | { status: `ready` } - | { status: `error`; error: unknown } = { status: `loading` } - const listeners = new Set<() => void>() - Object.defineProperty( - source.collection.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 0, - getOrStartNetworkDeadline: () => Date.now(), - getSnapshot: () => snapshot, - subscribe: (listener: () => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - }, - }, - ) - return () => { - snapshot = settledSnapshot - for (const listener of listeners) listener() - } - } - /** Register one unique law. */ const scenario = ( key: string, @@ -163,91 +125,6 @@ export function runSuite(rawDriver: LiveQueryDriver) { } describe(`live-query conformance :: ${driver.name}`, () => { - scenario( - `persisted-readiness`, - `every opted-in source restores before a joined query reports persisted readiness`, - async () => { - const left = driver.makeDeferredSource() - const right = driver.makeDeferredSource() - const settleLeft = installPersistedReadiness(left) - const settleRight = installPersistedReadiness(right) - const h = driver.mount((q) => - q - .from({ left: left.collection }) - .join({ right: right.collection }, ({ left: a, right: b }: any) => - ops.eq(a.id, b.id), - ), - ) - await h.flush() - expect(h.current()).toMatchObject({ - status: `loading`, - persistedStatus: `loading`, - isPersistedReady: false, - }) - - await h.apply(() => { - left.emit([SEED[0]!]) - right.emit([SEED[0]!]) - }) - await h.flush() - expect(h.current().data).toHaveLength(1) - - await h.apply(settleLeft) - await h.flush() - expect(h.current().persistedStatus).toBe(`loading`) - await h.apply(settleRight) - await h.flush() - expect(h.current()).toMatchObject({ - status: `loading`, - persistedStatus: `ready`, - isPersistedReady: true, - }) - }, - ) - - scenario( - `persisted-readiness-opt-out`, - `one unconfigured source keeps the persisted signal unavailable`, - async () => { - const opted = driver.makeDeferredSource() - const ordinary = driver.makeDeferredSource() - const settle = installPersistedReadiness(opted) - const h = driver.mount((q) => - q - .from({ opted: opted.collection }) - .join( - { ordinary: ordinary.collection }, - ({ opted: a, ordinary: b }: any) => ops.eq(a.id, b.id), - ), - ) - await h.apply(settle) - await h.flush() - expect(h.current()).toMatchObject({ - persistedStatus: `unavailable`, - isPersistedReady: false, - }) - }, - ) - scenario( - `persisted-error-identity`, - `a persisted restore failure keeps its original object identity`, - async () => { - const source = driver.makeDeferredSource() - const failure = { code: `restore-failed`, detail: { retryable: false } } - const failRestore = installPersistedReadiness(source, { - status: `error`, - error: failure, - }) - const h = driver.mount((q) => q.from({ items: source.collection })) - await h.flush() - expect(h.current().persistedStatus).toBe(`loading`) - - await h.apply(failRestore) - expect(h.current().persistedStatus).toBe(`error`) - expect(h.current().persistedError).toBe(failure) - expect(h.current().isPersistedReady).toBe(false) - }, - ) // ---- spine: query + liveness ---------------------------------------- scenario( @@ -1290,7 +1167,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(34) + expect(registry.size).toBe(31) }) }) } diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 234a220138..91241e6219 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -85,47 +85,6 @@ const flush = () => new Promise((r) => setTimeout(r, 0)) const ids = (snap: { data: ReadonlyArray }) => snap.data.map((r) => r.id) describe(`createLiveQueryWindowController`, () => { - it(`projects persisted readiness independently of the window status`, async () => { - const source = makeSource() - let persistedStatus: `loading` | `ready` = `loading` - const listeners = new Set<() => void>() - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 0, - getOrStartNetworkDeadline: () => Date.now(), - getSnapshot: () => ({ status: persistedStatus }), - subscribe: (listener: () => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - }, - }, - ) - const query = makeOrderedLiveQuery(source, 2) - const controller = createLiveQueryWindowController(query, { pageSize: 2 }) - const unsubscribe = controller.subscribe(() => {}) - try { - expect(controller.getSnapshot()).toMatchObject({ - persistedStatus: `loading`, - isPersistedReady: false, - }) - persistedStatus = `ready` - for (const listener of listeners) listener() - expect(controller.getSnapshot()).toMatchObject({ - persistedStatus: `ready`, - isPersistedReady: true, - }) - } finally { - unsubscribe() - controller.dispose() - await query.cleanup() - await source.cleanup() - } - }) - it.each( [0, 2, 5].flatMap((rowCount) => [`fetch`, `reset`, `dispose`].flatMap((action) => diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 4eea1d7864..d269ebed7c 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -86,7 +86,8 @@ * that no `errors:change` shows a status that `status:change` has not * announced, and that each `status:change` is followed at once by * `errors:change`. When the expected tree changed, the last event must show - * the current tree. Request lanes check that a lazy load or a root Collection's + * the current tree. `findProblems` must list, in tree order, exactly the + * expected nodes with a problem of their own. Request lanes check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's * node, and that it clears. Effect lanes check the Effect's gate and its * request errors after its initial-query readiness. @@ -106,7 +107,7 @@ */ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' -import { createCollection } from '../src/collection/index.js' +import { createCollection, findProblems } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createEffect } from '../src/query/effect.js' import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' @@ -391,6 +392,25 @@ function queryNode( } } +// `findProblems` lists, in tree order, the nodes whose own facts show a +// problem; a node whose problems are all below it is skipped. +type ProblemSummary = [alias: string, message: string] +function expectedProblems(node: ExpectedNode): Array { + const own = + (node.error ?? node.failureReason)?.message ?? + ((node.instances?.withErrors ?? 0) > 0 ? `instances` : null) + return [ + ...(own ? [[node.alias, own] as ProblemSummary] : []), + ...node.sources.flatMap(expectedProblems), + ] +} +function problemOf(node: ErrorTreeNode): ProblemSummary { + return [ + node.alias, + (node.error ?? node.failureReason)?.message ?? `instances`, + ] +} + function expectedTree( shape: Shape, model: Model, @@ -726,6 +746,10 @@ async function runHistory( const tree = expectedTree(shape, model) const next = query.errors expect(project(next), `${label}: error tree`).toEqual(tree) + expect( + findProblems(next).map(problemOf), + `${label}: findProblems`, + ).toEqual(expectedProblems(tree)) // Identity: a reader can compare trees with `===`. Reading twice gives // the same tree, and a step replaces exactly the nodes on the path to diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 0fd35357a4..7a5216d874 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -41,7 +41,6 @@ import type { ErrorTreeNode, InferResultType, InitialQueryBuilder, - LiveQueryPersistedStatus, LiveQueryWindowController, NonSingleResult, QueryBuilder, @@ -89,9 +88,6 @@ export type UseLiveInfiniteQueryReturnWithCollection< status: CollectionStatus isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean @@ -462,9 +458,6 @@ export function useLiveInfiniteQuery( status: snapshot.status as EnabledLiveQueryReturn[`status`], isLoading: snapshot.isLoading, isReady: snapshot.isReady, - persistedStatus: snapshot.persistedStatus, - isPersistedReady: snapshot.isPersistedReady, - persistedError: snapshot.persistedError, isIdle: snapshot.isIdle, isLoadingError: snapshot.isLoadingError, isCrashed: snapshot.isCrashed, diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 9388cc0b07..71d97ec550 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -33,7 +33,6 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -388,9 +387,6 @@ export function warnUnhashableDerivedIdentity( type UseLiveQueryFlags = { isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean @@ -959,7 +955,7 @@ function useLiveQueryImpl( (instance.collection?.status === `idle` || instance.collection?.status === `cleaned-up`) ) { - observer.preloadForInitialRender().catch(() => {}) + observer.preload().catch(() => {}) } // Stable subscribe bound to the current observer; the observer owns the diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index d9077abc77..9a435d33ac 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -248,7 +248,7 @@ export function useLiveSuspenseQuery( const collectionStatus = result.collection.status // Track when we reach ready state - if (result.isReady || queryInfo.observer.isInitialRenderReady()) { + if (result.isReady) { hasBeenReadyRef.current = true promiseRef.current = null clearInitialRenderError(result.collection, queryInfo.client) @@ -261,12 +261,8 @@ export function useLiveSuspenseQuery( queryInfo.client && queryInfo.queryHash ? queryInfo.client._getLiveQuery(queryInfo.queryHash) : undefined - // A configured persisted restore may still satisfy this render after the - // client query fails. Let the observer classify that failure below. if ( !hasBeenReadyRef.current && - (result.persistedStatus === `unavailable` || - result.persistedStatus === `error`) && (observerError !== undefined || clientQuery?.status === `error`) ) { promiseRef.current = null @@ -293,15 +289,13 @@ export function useLiveSuspenseQuery( // A crash throws at any time: the Collection can no longer serve reads. if ( collectionStatus === `crashed` || - (collectionStatus === `error` && - !hasBeenReadyRef.current && - (result.persistedStatus === `unavailable` || - result.persistedStatus === `error`)) + (collectionStatus === `error` && !hasBeenReadyRef.current) ) { promiseRef.current = null - // TODO: Once collections hold a reference to their last error object (#671), - // we should rethrow that actual error instead of creating a generic message - throw new Error(`Collection "${result.collection.id}" failed to load`) + throw ( + result.collection.errors.error?.cause ?? + new Error(`Collection "${result.collection.id}" failed to load`) + ) } if ( @@ -309,9 +303,7 @@ export function useLiveSuspenseQuery( (result.isLoading || result.isIdle || result.isLoadingError || - result.isCrashed || - (collectionStatus === `error` && - result.persistedStatus !== `unavailable`)) + result.isCrashed) ) { if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { const reason = queryInfo.identityError @@ -332,7 +324,7 @@ export function useLiveSuspenseQuery( }) let preload: Promise try { - preload = queryInfo.observer.preloadForInitialRender() + preload = queryInfo.observer.preload() } catch (error) { stopWatchingCleanup() throw error diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 930f010076..7916f711a0 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -161,9 +161,6 @@ function makeHandle(hook: RenderHookResult) { state: r?.state, status: r?.status, isReady: r?.isReady, - persistedStatus: r?.persistedStatus, - isPersistedReady: r?.isPersistedReady, - persistedError: r?.persistedError, isLoadingError: r?.isLoadingError, isCrashed: r?.isCrashed, isSyncError: r?.isSyncError, diff --git a/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx index 2c5136a029..58ff15ade4 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx @@ -69,164 +69,6 @@ function setup(id: string) { } describe(`useLiveSuspenseQuery preload recovery`, () => { - it.each([ - { - name: `restored row, stream fails afterward`, - rows: [{ id: `cached` }], - failureBeforeRestore: false, - failureBeforeMount: false, - }, - { - name: `restored row, stream fails first`, - rows: [{ id: `cached` }], - failureBeforeRestore: true, - failureBeforeMount: false, - }, - { - name: `empty restore, stream fails afterward`, - rows: [], - failureBeforeRestore: false, - failureBeforeMount: false, - }, - { - name: `empty restore, stream fails first`, - rows: [], - failureBeforeRestore: true, - failureBeforeMount: false, - }, - { - name: `restored row, stream failed before mount`, - rows: [{ id: `cached` }], - failureBeforeRestore: true, - failureBeforeMount: true, - }, - ])( - `renders a completed persisted query after a client stream fails: $name`, - async ({ name, rows, failureBeforeRestore, failureBeforeMount }) => { - let sync!: { - begin: () => void - write: (row: Person) => void - commit: () => void - } - const source = createCollection({ - id: `fallback-${name}-source`, - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit }) => { - sync = { - begin, - write: (row) => write({ type: `insert`, value: row }), - commit, - } - return {} - }, - }, - }) - let persistedStatus: `loading` | `ready` = `loading` - const readinessListeners = new Set<() => void>() - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: persistedStatus }), - subscribe: (listener: () => void) => { - readinessListeners.add(listener) - return () => readinessListeners.delete(listener) - }, - }, - }, - ) - const query = createLiveQueryCollection((q) => q.from({ person: source })) - const client = new DbClient() - const hash = getStableValueHash([`collection`, query.id], `queryKey`) - let rejectStream!: (error: Error) => void - const stream = new Promise<{ rows: Array }>((_, reject) => { - rejectStream = reject - }) - void client._registerLiveQuery(hash, stream).catch(() => {}) - if (failureBeforeMount) { - rejectStream(new Error(`stream failed`)) - await stream.catch(() => {}) - expect(client._getLiveQuery(hash)?.status).toBe(`error`) - } - const consoleError = vi - .spyOn(console, `error`) - .mockImplementation(() => {}) - function View() { - const result = useLiveSuspenseQuery(query) - const ids = result.data.map((row) => row.id) - return ( -
    {ids.length ? `Rows: ${ids.join(`,`)}` : `Rows: (empty)`}
    - ) - } - const view = render( - - - Waiting for network
    }> - -
    - - , - ) - try { - if (failureBeforeMount) expect(query.status).toBe(`loading`) - expect(view.getByText(`Waiting for network`)).toBeTruthy() - const restore = async () => { - await act(() => { - if (rows.length > 0) { - sync.begin() - rows.forEach(sync.write) - sync.commit() - } - persistedStatus = `ready` - readinessListeners.forEach((listener) => listener()) - }) - await waitFor(() => - expect(query.toArray.map((row) => row.id)).toEqual( - rows.map((row) => row.id), - ), - ) - expect(source.status).toBe(`loading`) - expect(query.status).toBe(`loading`) - } - const failStream = async () => { - await act(async () => { - rejectStream(new Error(`stream failed`)) - await stream.catch(() => {}) - }) - } - if (failureBeforeMount) { - await restore() - } else if (failureBeforeRestore) { - await failStream() - expect(view.getByText(`Waiting for network`)).toBeTruthy() - await restore() - } else { - await restore() - expect(view.getByText(`Waiting for network`)).toBeTruthy() - await failStream() - } - await waitFor(() => - expect( - view.getByText( - rows.length - ? `Rows: ${rows.map((row) => row.id).join(`,`)}` - : `Rows: (empty)`, - ), - ).toBeTruthy(), - ) - } finally { - view.unmount() - consoleError.mockRestore() - await query.cleanup() - await source.cleanup() - } - }, - ) - it.each([ { name: `Error`, failure: new Error(`first stream failed`) }, { name: `undefined`, failure: undefined }, diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index ea63320f23..e9872e7d3c 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -88,18 +88,6 @@ describe(`useLiveSuspenseQuery`, () => { getKey: (row) => row.id, sync: { sync: () => ({}) }, }) - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: `ready` }), - subscribe: () => () => {}, - }, - }, - ) const query = createLiveQueryCollection((q) => q.from({ person: source })) const failure = new Error(`query failed independently of its source`) const preload = vi.spyOn(query, `preload`).mockRejectedValue(failure) @@ -153,18 +141,6 @@ describe(`useLiveSuspenseQuery`, () => { }, }, }) - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: `ready` }), - subscribe: () => () => {}, - }, - }, - ) const query = createLiveQueryCollection((q) => q.from({ person: source }).where(({ person }) => gt(person.age, 20)), ) @@ -305,18 +281,6 @@ describe(`useLiveSuspenseQuery`, () => { getKey: (row) => row.id, sync: { sync: () => ({}) }, }) - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: `ready` }), - subscribe: () => () => {}, - }, - }, - ) const query = createLiveQueryCollection((q) => q.from({ person: source })) const failure = new Error(`first query run failed`) const pendingRetry = new Promise(() => {}) @@ -363,120 +327,6 @@ describe(`useLiveSuspenseQuery`, () => { } }) - it.each([ - { - name: `immediate persisted fallback`, - networkTimeoutMs: 0, - outcome: `pending`, - }, - { - name: `persisted fallback after the deadline`, - networkTimeoutMs: 30, - outcome: `timeout`, - }, - { name: `network readiness`, networkTimeoutMs: 60_000, outcome: `ready` }, - { - name: `early network failure`, - networkTimeoutMs: 60_000, - outcome: `error`, - }, - ] as const)( - `gates the first render: $name`, - async ({ networkTimeoutMs, outcome }) => { - let sync: - | { - begin: () => void - write: (row: Person) => void - commit: () => void - markReady: () => void - fail: (error: unknown) => void - } - | undefined - const source = createCollection({ - id: `suspense-persisted-${outcome}`, - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady, fail }) => { - sync = { - begin, - write: (row) => write({ type: `insert`, value: row }), - commit: () => { - commit() - }, - markReady, - fail, - } - return {} - }, - }, - }) - let persistedStatus: `loading` | `ready` = `loading` - let networkDeadlineAt: number | undefined - const listeners = new Set<() => void>() - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs, - getOrStartNetworkDeadline: () => - (networkDeadlineAt ??= Date.now() + networkTimeoutMs), - getSnapshot: () => ({ status: persistedStatus }), - subscribe: (listener: () => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - }, - }, - ) - let cleanupQuery: (() => Promise) | undefined - - function LocalPerson() { - const result = useLiveSuspenseQuery((q) => q.from({ person: source })) - cleanupQuery = () => result.collection.cleanup() - const { data } = result - return
    {data[0]?.name}
    - } - const statusView = renderHook(() => useLiveQuery(source)) - const view = renderComponent( - Waiting for network}> - - , - ) - try { - expect(view.getByText(`Waiting for network`)).toBeTruthy() - await waitFor(() => expect(sync).toBeDefined()) - await act(() => { - sync!.begin() - sync!.write(initialPersons[0]!) - sync!.commit() - persistedStatus = `ready` - for (const listener of listeners) listener() - }) - await waitFor(() => - expect(statusView.result.current.persistedStatus).toBe(`ready`), - ) - if (outcome === `pending` || outcome === `timeout`) { - await waitFor(() => expect(view.getByText(`John Doe`)).toBeTruthy()) - expect(source.status).toBe(`loading`) - } else { - expect(view.getByText(`Waiting for network`)).toBeTruthy() - await act(() => { - if (outcome === `ready`) sync!.markReady() - else sync!.fail(new Error(`network failed`)) - }) - await waitFor(() => expect(view.getByText(`John Doe`)).toBeTruthy()) - expect(source.status).toBe(outcome) - } - } finally { - view.unmount() - statusView.unmount() - await cleanupQuery?.() - await source.cleanup() - } - }, - ) - it(`renders a streamed query snapshot until browser sync is authoritative`, async () => { let resolveServerLoad!: () => void const serverLoad = new Promise((resolve) => { diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 511f6eda15..79edccc3b4 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -29,7 +29,6 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -113,9 +112,6 @@ type InferConditionalResultType = type UseLiveQueryFlags = { isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean @@ -348,9 +344,6 @@ export function useLiveQuery( name: `TanstackDBStatus`, }, ) - const [persistedStatus, setLocalStatus] = - createSignal(`unavailable`) - const [persistedError, setLocalError] = createSignal(undefined) const [errors, setErrors] = createSignal(undefined) // Helper to sync data array from collection in correct order @@ -407,7 +400,7 @@ export function useLiveQuery( const observer = createLiveQueryObserver(currentCollection) localWaitObserver = observer try { - await observer.preloadForInitialRender() + await observer.preload() } finally { observer.dispose() if (localWaitObserver === observer) localWaitObserver = undefined @@ -443,8 +436,6 @@ export function useLiveQuery( const currentCollection = collection() if (!currentCollection) { setStatus(`disabled` as const) - setLocalStatus(`unavailable`) - setLocalError(undefined) setErrors(undefined) state.clear() rowsByKey.clear() @@ -492,8 +483,6 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) const snapshot = observer.getSnapshot() setStatus(snapshot.status) - setLocalStatus(snapshot.persistedStatus) - setLocalError(snapshot.persistedError) setErrors(snapshot.errors) }) }, @@ -505,8 +494,6 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) const snapshot = observer.getSnapshot() setStatus(snapshot.status) - setLocalStatus(snapshot.persistedStatus) - setLocalError(snapshot.persistedError) setErrors(snapshot.errors) }) @@ -560,21 +547,6 @@ export function useLiveQuery( return status() === `ready` || status() === `disabled` }, }, - persistedStatus: { - get() { - return persistedStatus() - }, - }, - isPersistedReady: { - get() { - return persistedStatus() === `ready` - }, - }, - persistedError: { - get() { - return persistedError() - }, - }, isIdle: { get() { return status() === `idle` diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index d46b6e252d..0cc8761028 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -128,9 +128,6 @@ function makeHandle( state: result?.state, status: result?.status, isReady: result?.isReady, - persistedStatus: result?.persistedStatus, - isPersistedReady: result?.isPersistedReady, - persistedError: result?.persistedError, isLoadingError: result?.isLoadingError, isCrashed: result?.isCrashed, isSyncError: result?.isSyncError, diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index b2600f3150..219f32e6fe 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -2123,119 +2123,6 @@ describe(`Query Collections`, () => { }) describe(`Suspense Integration`, () => { - it.each([ - { - name: `immediate persisted fallback`, - networkTimeoutMs: 0, - outcome: `pending`, - }, - { - name: `persisted fallback after the deadline`, - networkTimeoutMs: 30, - outcome: `timeout`, - }, - { name: `network readiness`, networkTimeoutMs: 60_000, outcome: `ready` }, - { - name: `early network failure`, - networkTimeoutMs: 60_000, - outcome: `error`, - }, - ] as const)( - `gates the first render: $name`, - async ({ networkTimeoutMs, outcome }) => { - let sync: - | { - begin: () => void - write: (row: Person) => void - commit: () => void - markReady: () => void - fail: (error: unknown) => void - } - | undefined - const source = createCollection({ - id: `solid-persisted-${outcome}`, - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady, fail }) => { - sync = { - begin, - write: (row) => write({ type: `insert`, value: row }), - commit: () => { - commit() - }, - markReady, - fail, - } - return {} - }, - }, - }) - let persistedStatus: `loading` | `ready` = `loading` - let networkDeadlineAt: number | undefined - const listeners = new Set<() => void>() - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs, - getOrStartNetworkDeadline: () => - (networkDeadlineAt ??= Date.now() + networkTimeoutMs), - getSnapshot: () => ({ status: persistedStatus }), - subscribe: (listener: () => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - }, - }, - ) - let cleanupQuery: (() => Promise) | undefined - const statusView = renderHook(() => useLiveQuery(() => source)) - - function LocalPerson() { - const query = useLiveQuery((q) => q.from({ person: source })) - cleanupQuery = () => query.collection.cleanup() - return
    {query()[0]?.name}
    - } - const view = render(() => ( - Waiting}> - - - )) - try { - await waitFor(() => expect(sync).toBeDefined()) - sync!.begin() - sync!.write(initialPersons[0]!) - sync!.commit() - persistedStatus = `ready` - for (const listener of listeners) listener() - await waitFor(() => - expect(statusView.result.persistedStatus).toBe(`ready`), - ) - expect(statusView.result()[0]?.name).toBe(`John Doe`) - if (outcome === `pending` || outcome === `timeout`) { - const content = await view.findByTestId(`local-person`) - expect(content.textContent).toBe(`John Doe`) - expect(source.status).toBe(`loading`) - } else { - expect(view.getByTestId(`local-waiting`)).toBeTruthy() - if (outcome === `ready`) sync!.markReady() - else sync!.fail(new Error(`network failed`)) - } - const content = await view.findByTestId(`local-person`) - expect(content.textContent).toBe(`John Doe`) - if (outcome === `ready` || outcome === `error`) { - expect(source.status).toBe(outcome) - } - } finally { - view.unmount() - statusView.cleanup() - await cleanupQuery?.() - await source.cleanup() - } - }, - ) - it(`should work with Suspense boundaries`, async () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index f55849f18f..07ba3b2804 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -250,15 +250,6 @@ export function useLiveInfiniteQuery( get isReady() { return snapshot.isReady }, - get persistedStatus() { - return snapshot.persistedStatus - }, - get isPersistedReady() { - return snapshot.isPersistedReady - }, - get persistedError() { - return snapshot.persistedError - }, get isIdle() { return snapshot.isIdle }, diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index e7ca1c5428..28902b8598 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -31,7 +31,6 @@ import type { LiveQueryCollectionConfig, LiveQueryKey, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -64,9 +63,6 @@ export interface UseLiveQueryReturn> { status: CollectionStatus isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean @@ -103,9 +99,6 @@ export interface UseLiveQueryReturnWithCollection< status: CollectionStatus isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean @@ -473,8 +466,6 @@ export function useLiveQuery( // Track collection status reactively let status = $state(initialSnapshot.status) - let persistedStatus = $state(initialSnapshot.persistedStatus) - let persistedError = $state.raw(initialSnapshot.persistedError) let errors = $state.raw(initialSnapshot.errors) const syncFromObserver = ( @@ -483,8 +474,6 @@ export function useLiveQuery( ) => { const snapshot = observer.getSnapshot() status = snapshot.status as CollectionStatus - persistedStatus = snapshot.persistedStatus - persistedError = snapshot.persistedError errors = snapshot.errors untrack(() => { if (changes && changes.length > 0) { @@ -570,15 +559,6 @@ export function useLiveQuery( get isReady() { return status === `ready` || status === `disabled` }, - get persistedStatus() { - return persistedStatus - }, - get isPersistedReady() { - return persistedStatus === `ready` - }, - get persistedError() { - return persistedError - }, get isIdle() { return status === `idle` }, diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index 264fd01697..27ff42ed70 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -125,9 +125,6 @@ function makeHandle(getQuery: () => any, dispose: () => void): LiveQueryHandle { state: query?.state, status: query?.status, isReady: query?.isReady, - persistedStatus: query?.persistedStatus, - isPersistedReady: query?.isPersistedReady, - persistedError: query?.persistedError, isLoadingError: query?.isLoadingError, isCrashed: query?.isCrashed, isSyncError: query?.isSyncError, diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index e3c613d548..443ac93661 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -18,7 +18,6 @@ import type { GetResult, InferResultType, InitialQueryBuilder, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, UtilsRecord, @@ -57,9 +56,6 @@ export interface UseLiveInfiniteQueryReturn< status: ComputedRef isLoading: ComputedRef isReady: ComputedRef - persistedStatus: ComputedRef - isPersistedReady: ComputedRef - persistedError: ComputedRef isIdle: ComputedRef /** The first load failed: there is nothing to show. */ isLoadingError: ComputedRef @@ -87,9 +83,6 @@ export interface UseLiveInfiniteQueryReturnWithCollection< status: ComputedRef isLoading: ComputedRef isReady: ComputedRef - persistedStatus: ComputedRef - isPersistedReady: ComputedRef - persistedError: ComputedRef isIdle: ComputedRef /** The first load failed: there is nothing to show. */ isLoadingError: ComputedRef @@ -265,9 +258,6 @@ export function useLiveInfiniteQuery< status: computed(() => snapshot.value.status as CollectionStatus), isLoading: computed(() => snapshot.value.isLoading), isReady: computed(() => snapshot.value.isReady), - persistedStatus: computed(() => snapshot.value.persistedStatus), - isPersistedReady: computed(() => snapshot.value.isPersistedReady), - persistedError: computed(() => snapshot.value.persistedError), isIdle: computed(() => snapshot.value.isIdle), isLoadingError: computed(() => snapshot.value.isLoadingError), isCrashed: computed(() => snapshot.value.isCrashed), diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 9d3289af51..7fdedec64c 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -28,7 +28,6 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -58,9 +57,6 @@ export interface UseLiveQueryFlags { status: ComputedRef isLoading: ComputedRef isReady: ComputedRef - persistedStatus: ComputedRef - isPersistedReady: ComputedRef - persistedError: ComputedRef isIdle: ComputedRef /** The first load failed: there is nothing to show. */ isLoadingError: ComputedRef @@ -367,8 +363,6 @@ export function useLiveQuery( const status = ref( collection.value ? collection.value.status : (`disabled` as const), ) - const persistedStatus = ref(`unavailable`) - const persistedError = shallowRef(undefined) const errors = shallowRef(undefined) // The shared observer owns subscription, the ready-race, and status; Vue @@ -378,8 +372,6 @@ export function useLiveQuery( const syncFromObserver = (observer: LiveQueryObserver) => { const snapshot = observer.getSnapshot() status.value = snapshot.status as CollectionStatus - persistedStatus.value = snapshot.persistedStatus - persistedError.value = snapshot.persistedError errors.value = snapshot.errors internalData.value = snapshotRows(snapshot.data) } @@ -395,8 +387,6 @@ export function useLiveQuery( // Handle null collection (disabled query) if (!currentCollection) { status.value = `disabled` as const - persistedStatus.value = `unavailable` - persistedError.value = undefined errors.value = undefined state.clear() internalData.value = [] @@ -460,9 +450,6 @@ export function useLiveQuery( isReady: computed( () => status.value === `ready` || status.value === `disabled`, ), - persistedStatus: computed(() => persistedStatus.value), - isPersistedReady: computed(() => persistedStatus.value === `ready`), - persistedError: computed(() => persistedError.value), isIdle: computed(() => status.value === `idle`), isLoadingError: computed(() => status.value === `error`), isCrashed: computed(() => status.value === `crashed`), diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 679283a151..59b7e742e6 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -124,9 +124,6 @@ function makeHandle(result: any, scope: ReturnType) { state: result.state?.value, status: result.status?.value, isReady: result.isReady?.value, - persistedStatus: result.persistedStatus?.value, - isPersistedReady: result.isPersistedReady?.value, - persistedError: result.persistedError?.value, isLoadingError: result.isLoadingError?.value, isCrashed: result.isCrashed?.value, isSyncError: result.isSyncError?.value, From a01a9dd530f620b46188ef5e5b7ea36f7ede2a80 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 16 Oct 2026 13:55:05 -0700 Subject: [PATCH 061/106] docs: describe the fail-then-reload cleanup-restart history Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 23ab0c7b01..37341aad9c 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -94,7 +94,8 @@ that test identifiers must copy production's private data structures. The Collection lifecycle history owner includes a 26-cell cleanup-restart matrix. It crosses explicit and default eager sources with absent or present loaders, empty or nonempty replacements, and no interruption, truncate during -loading, or recoverable error before same-run readiness. On-demand controls use +loading, or a failed first load followed by `reload()`, which must keep the +waiting caller for the new sync run. On-demand controls use settled subset acquisition. A cleanup-detached eager demand remains active, its `onLoadSubsetResult` promise stays pending through these pre-ready cuts, and it starts no physical subset acquisition before fulfilling with the exact From 807ac2bf779aae9b799b7e30ffffc433c5ebebb5 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 16 Oct 2026 14:00:03 -0700 Subject: [PATCH 062/106] build: refresh the mangle cache and check the new collection errors Stage 4 removed the persisted readiness members from the observer, so the private-member cache drops them. The minified public API check now expects CollectionHasSubscribersError, CollectionReloadCrashedError, and CollectionReloadDuringSyncEntryError in the CollectionStateError family. Co-authored-by: Isaac --- packages/db/mangle-cache.json | 14 +++++++------- scripts/test-minified-db.mjs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 5cf1d83793..109a6620cc 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -52,7 +52,6 @@ "truncateReplacementPending": "aR", "lastLiveQueryTimestamp": "aS", "pendingChanges": "aT", - "readPersistedReadiness": "aU", "statusRevision": "aV", "enrichWithVirtualPropsSnapshot": "aW", "lease": "aX", @@ -64,7 +63,6 @@ "_events": "a3", "isDemandActive": "a4", "loadSubsetOperations": "a5", - "cancelInitialRenderWait": "a6", "pendingSyncedProjection": "a7", "hasHydrationError": "a9", "orderedLoadFailed": "ba", @@ -88,7 +86,6 @@ "appliedLimit": "bt", "cachedCollectionLayoutRevision": "bu", "collectionsById": "bv", - "initialRenderPromise": "bw", "maybeRunGraphFn": "bx", "preloadedLiveQueries": "bz", "rebuildPendingSyncedProjection": "bA", @@ -128,7 +125,6 @@ "cachedCollectionRevision": "ca", "collectionRestartCleanup": "cb", "pendingHydration": "cd", - "persistedSources": "ce", "removeFromBucket": "cf", "restorePublishedSnapshotTracking": "cg", "scheduleHydrationHandoff": "ch", @@ -193,7 +189,6 @@ "clearOriginTrackingState": "dh", "handleSourceCleanupStart": "di", "handleSourceStatusChange": "dj", - "initialRenderReady": "dk", "lastBoundary": "dl", "microtaskScheduled": "dm", "resolveValue": "dn", @@ -395,7 +390,6 @@ "beginWrite": "gB", "detach": "gC", "copyRows": "gD", - "restore": "gE", "attach": "gF", "sortedExactValues": "gG", "reachedReady": "cc", @@ -429,5 +423,11 @@ "markReloadTruncate": "g1", "commitEmptyReloadTruncate": "g2", "dependentEntry": "g3", - "enterSync": "g4" + "enterSync": "g4", + "retiringSupersededAcquisitions": "aU", + "needsOptimisticRefill": "a6", + "graphCheckedDuringRequest": "bw", + "ownPrefixCount": "ce", + "graphHasProcessedInput": "dk", + "windowIsInsideOwnPrefix": "gE" } diff --git a/scripts/test-minified-db.mjs b/scripts/test-minified-db.mjs index 03cbaad5b8..f09e7f3428 100644 --- a/scripts/test-minified-db.mjs +++ b/scripts/test-minified-db.mjs @@ -81,7 +81,7 @@ const errorGroups = { DuplicateDbInstanceError: `DuplicateDbInstanceError`, CollectionConfigurationError: `CollectionConfigurationError CollectionRequiresConfigError CollectionRequiresSyncConfigError InvalidSchemaError SchemaMustBeSynchronousError`, InvalidSyncPersistenceCapabilityError: `InvalidSyncPersistenceCapabilityError`, - CollectionStateError: `CollectionStateError CollectionInErrorStateError InvalidCollectionStatusTransitionError CollectionIsInErrorStateError NegativeActiveSubscribersError LiveQueryObserverDisposedError LiveQueryWindowControllerDisposedError`, + CollectionStateError: `CollectionStateError CollectionInErrorStateError InvalidCollectionStatusTransitionError CollectionIsInErrorStateError NegativeActiveSubscribersError LiveQueryObserverDisposedError LiveQueryWindowControllerDisposedError CollectionHasSubscribersError CollectionReloadCrashedError CollectionReloadDuringSyncEntryError`, CollectionOperationError: `CollectionOperationError UndefinedKeyError InvalidKeyError DuplicateKeyError DuplicateKeySyncError MissingUpdateArgumentError NoKeysPassedToUpdateError UpdateKeyNotFoundError KeyUpdateNotAllowedError NoKeysPassedToDeleteError DeleteKeyNotFoundError`, MissingHandlerError: `MissingHandlerError MissingInsertHandlerError MissingUpdateHandlerError MissingDeleteHandlerError`, TransactionError: `TransactionError MissingMutationFunctionError TransactionNotPendingMutateError TransactionAlreadyCompletedRollbackError DuplicateTransactionIdError TransactionNotPendingCommitError NoPendingSyncTransactionWriteError SyncTransactionAlreadyCommittedWriteError SyncRowReusedWithoutPreviousValueError NoPendingSyncTransactionCommitError SyncTransactionAlreadyCommittedError SyncQueueInvariantError`, From 24c5c3bb2afd10bab02c61eb36cc7058773f0654 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 16 Oct 2026 14:07:01 -0700 Subject: [PATCH 063/106] test(persistence): a follower's failed apply crashes its source When a follower tab or renderer fails to apply its owner's commit, the failure is a local persistence failure. Under this branch's contract that crashes the follower Collection instead of leaving it in error. Update the browser coordinator and Electron IPC readiness models and their prose. Co-authored-by: Isaac --- .../tests/per-collection-coordinator-oracle.test.ts | 5 +++-- .../tests/electron-ipc-readiness-oracle.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts index d94319f941..0a646b7c7f 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts @@ -5012,7 +5012,8 @@ and one real SQLite file opened by both tabs. Independent model: it knows only whether the owner's commit applies in the follower. While that apply is outstanding, the consumer has not settled. If it applies, the consumer settles successfully with the owner's row. If it fails, -the consumer rejects without the row and the follower source is in error. +the consumer rejects without the row. The failed apply is a local persistence +failure, so the follower source crashes. Grammar: the owner's source is a receipt-awaiting custom source or a real Query Collection source, crossed with the placement of the owner's broadcast @@ -5089,7 +5090,7 @@ function expectedRealTabFollower(history: RealTabHistory) { settled: { outcome: applied ? `fulfilled` : `rejected`, rows: applied ? [`remote`] : undefined, - source: applied ? `ready` : `error`, + source: applied ? `ready` : `crashed`, }, } } diff --git a/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts b/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts index 285669f087..2a4e5d009a 100644 --- a/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts +++ b/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts @@ -406,8 +406,9 @@ describe(`Electron renderer initial readiness follows the composed load`, () => * The first renderer owns the Collection and runs the source; the second is a * follower and routes its demand to the owner. Law 7 owes the follower: its * load, and its query's first ready, settle only once the owner's rows are - * visible in the follower, and they reject, with the follower source in - * error, if applying the owner's commit fails here. + * visible in the follower, and they reject if applying the owner's commit + * fails here. That failure is a local persistence failure, so the follower + * source crashes. * * The model knows only whether the owner's commit applies in the follower. * The grammar crosses that outcome with the consumer: a live query over the @@ -429,7 +430,7 @@ function expectedCoordinatedRenderer(history: CoordinatedRendererHistory) { settledWhileApplyHeld: false, outcome: applied ? `fulfilled` : `rejected`, rows: applied ? [`remote`] : undefined, - source: applied ? `ready` : `error`, + source: applied ? `ready` : `crashed`, } } From f3cd7103d518819363e2d7c6e558034e822e7ab9 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sun, 18 Oct 2026 07:18:06 -0700 Subject: [PATCH 064/106] test(indexed-db): a closed native connection crashes the Collection The native cross-tab, value, and host oracles expected status error after an explicit close, an upgrade or deletion versionchange, or forced storage clearing. Under this branch's contract a closed IndexedDB connection is a crash: local storage can no longer serve reads. Update the expectations and the prose that names the retained status. Co-authored-by: Isaac --- .../db/e2e/indexed-db/cross-tab-oracle.spec.ts | 18 +++++++++--------- packages/db/e2e/indexed-db/host-oracle.spec.ts | 4 ++-- .../db/e2e/indexed-db/value-oracle.spec.ts | 4 +++- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts index 7a0369bcb4..ac09a5230e 100644 --- a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts @@ -971,7 +971,7 @@ test('an old native deletion receipt cannot complete a newer blocked deletion', const before = await observe(peer) await old.evaluate(() => window.crossTab!.releaseDeletionReceipt()) const after = await observe(peer) - expect(after.status).toBe('error') + expect(after.status).toBe('crashed') expect(after.callbacks.completed).toBe(before.callbacks.completed) const durable = await owner.evaluate(() => window.crossTab!.blockerRows()) const state = await observe(owner) @@ -996,7 +996,7 @@ test('an old native deletion receipt cannot complete a newer blocked deletion', // Native receiving lane for the formal finish policy. A real transaction holds // each admitted adapter write while another page requests upgrade/deletion (or // the owner explicitly closes). Success/abort, caller-time source rows, retained -// error status, and raw storage are separate observations. The unmanaged read +// crashed status, and raw storage are separate observations. The unmanaged read // connection keeps administrative deletion pending until those checks finish. for (const reason of ['explicit', 'upgrade', 'delete'] as const) for (const kind of ['insert', 'update', 'delete', 'clear', 'import'] as const) @@ -1033,7 +1033,7 @@ for (const reason of ['explicit', 'upgrade', 'delete'] as const) () => window.crossTab!.observe().versionChanges === 1, ) } - expect.soft((await observe(a)).status).toBe('error') + expect.soft((await observe(a)).status).toBe('crashed') await a.evaluate(() => window.crossTab!.releaseBarrier()) await a.evaluate(() => window.crossTab!.retirementDone()) const after = await observe(a) @@ -1066,13 +1066,13 @@ for (const reason of ['explicit', 'upgrade', 'delete'] as const) expected, 'native retired disk rows', ) - expect(after.status).toBe('error') - const retiredAt = after.statuses.indexOf('error') + expect(after.status).toBe('crashed') + const retiredAt = after.statuses.indexOf('crashed') expect(retiredAt).toBeGreaterThanOrEqual(0) expect( after.statuses .slice(retiredAt) - .every((status) => status === 'error'), + .every((status) => status === 'crashed'), ).toBe(true) await a.evaluate(() => window.crossTab!.closeBlocker()) if (reason !== 'explicit') @@ -1098,7 +1098,7 @@ for (const transactionFirst of [false, true]) await a.waitForFunction( () => window.crossTab!.observe().versionChanges === 1, ) - expect((await observe(a)).status).toBe('error') + expect((await observe(a)).status).toBe('crashed') if (transactionFirst) { await a.evaluate(() => window.crossTab!.releaseBarrier()) expect((await observe(a)).writes.at(-1)).toBe('complete') @@ -1119,7 +1119,7 @@ for (const transactionFirst of [false, true]) await b.waitForFunction( () => window.crossTab!.observe().schema.status === 'complete', ) - expect((await observe(a)).status).toBe('error') + expect((await observe(a)).status).toBe('crashed') }) }) @@ -1183,7 +1183,7 @@ for (const phase of [ if (!loading) await page.evaluate(() => window.crossTab!.retirementDone()) const after = await observe(page) - expect(after.status).toBe('error') + expect(after.status).toBe('crashed') assertSnapshot(after.rows, before, 'forced-close retained snapshot') if (loading) expect(after.reads.at(-1)).toBe('abort') else { diff --git a/packages/db/e2e/indexed-db/host-oracle.spec.ts b/packages/db/e2e/indexed-db/host-oracle.spec.ts index b42390af44..96aca250ec 100644 --- a/packages/db/e2e/indexed-db/host-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/host-oracle.spec.ts @@ -176,7 +176,7 @@ test('dedicated worker and page preserve writes, restore and closure', async ({ await call(page, { type: 'close' }, true) expect(await call(page, { type: 'status' }, true)).toEqual({ nativeClose: 0, - collections: [{ store, status: 'error', rows: model.get(store) }], + collections: [{ store, status: 'crashed', rows: model.get(store) }], }) await expect( call( @@ -223,7 +223,7 @@ test('native storage clearing reports abnormal closure and retains the public sn .poll(() => call(page, { type: 'status' })) .toEqual({ nativeClose: 1, - collections: [{ store: 'items', status: 'error', rows }], + collections: [{ store: 'items', status: 'crashed', rows }], }) await expect( call(page, { diff --git a/packages/db/e2e/indexed-db/value-oracle.spec.ts b/packages/db/e2e/indexed-db/value-oracle.spec.ts index 7f81dfcc93..9308ba153e 100644 --- a/packages/db/e2e/indexed-db/value-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/value-oracle.spec.ts @@ -220,9 +220,11 @@ for (const shared of [true, false]) { expect( await page.evaluate(() => window.initialization.fresh()), ).toEqual(expected) + // A versionchange closes each connection: local storage can no longer + // serve reads, so every Collection crashes. expect( await page.evaluate(() => window.initialization.upgrade()), - ).toEqual({ blocked: 0, statuses: ['error', 'error', 'error'] }) + ).toEqual({ blocked: 0, statuses: ['crashed', 'crashed', 'crashed'] }) }) }) } From 761969e2f7972805338365bf6d3ec81a89948079 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 07:58:39 -0600 Subject: [PATCH 065/106] fix(db): close reload, request-order, and persisted report-order gaps Review fixes, each with an oracle witness that failed before the change: - A retired run's cleanup that rejects during reload() crashes the Collection instead of dropping the reload mid-run; an abandoned reload emits errors:change. - reload() on a live query reaches every Collection below it that shows an error, through nested live queries. A crashed query rejects without touching its sources. - A reload's first commit keeps a failureReason its new run reported. - Retiring a sync run drops its open, uncommitted sync transaction. - A successful load clears only request errors of loads started before it (live query root loads and Effects share LoadOrder). - A query auto-restarts from error only when a source's failed first load caused the failure, not after its own failed load. - The persisted wrapper delivers markReady, reportError, and reportHealthy to core in the adapter's call order. - The observer reads the error tree once per errors:change. Oracle: source-health gains reload-cleanup-fails and reload-query steps, named histories, an overlapping-root-load lane, an own-failure lane, and a reload invariant witness; persisted readiness gains a ready-report remote. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 12 +- .../src/persisted.ts | 39 +- .../tests/persisted-readiness-oracle.test.ts | 30 +- packages/db/mangle-cache.json | 8 +- packages/db/src/collection/lifecycle.ts | 41 ++- packages/db/src/collection/state.ts | 10 + packages/db/src/collection/sync.ts | 4 +- packages/db/src/live-query-observer.ts | 5 +- packages/db/src/query/effect.ts | 10 +- .../query/live/collection-config-builder.ts | 19 +- .../src/query/live/collection-subscriber.ts | 7 +- .../db/tests/source-health-oracle.test.ts | 335 +++++++++++++++++- 12 files changed, 453 insertions(+), 67 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 37341aad9c..264bbe93d8 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection health and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the initial gate, health problems with serials, and crashes derives each live query's gate and its expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, and restart; the check compares status, rows, the projected tree, node identity, and `status:change`/`health:change` ordering after every step. Request and Effect lanes cover request errors and Effect disposal on an initial error or crash. The infinite-query oracle keeps the window's pages and `ready` status after a failed window request. The shared framework conformance scenario `sync-errors-after-readiness` judges the same law through every hook. Adapter receivers: Electric's process grammar and partition laws, TrailBase's stream-end lane, PowerSync's rebuild outage, query-db's failed-refetch witness, and the persisted durability lanes. Open: on-demand sources that fail their own initial sync after a live query is ready are generated only in the Effect lane; a Suspense render after readiness has no direct framework witness; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a success clears only failures of loads started before it), a query's own failed first load not restarting when an unrelated source becomes ready, a reload invariant witness that drops the retired run's open sync transaction, and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Open: the Effect's overlapping-load ordering has no witness (it shares `LoadOrder` with the live query); an abandoned reload's `errors:change` without a crash (a rejected truncate receipt) has no witness; on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | @@ -2421,10 +2421,12 @@ The persisted-readiness oracle in `packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts` owns when a persisted Collection with network-first initial rendering opens from restored rows. A small model enumerates every ordering of the restore -outcome (rows, empty, failed), the remote answer (ready, fail, none), and the -deadline, 57 histories. After every event it compares Collection status, the -node's `error` message, the visible rows while `ready`, and the status of a live -query over the Collection. The laws: restored rows open the Collection once the +outcome (rows, empty, failed), the remote answer (ready, ready followed at once +by `reportError()`, fail, none), and the deadline, 76 histories. After every +event it compares Collection status, the node's `error` and `failureReason` +messages, the visible rows while `ready`, and the status of a live query over +the Collection. The `ready-report` histories check that the wrapper delivers the +adapter's calls to core in the order it made them. The laws: restored rows open the Collection once the deadline passes or the remote fails; an empty restore stays `loading`; a remote answer before the restore completes waits for it; a failed restore crashes. Separate cases check that a Collection without `initialRender` waits for the diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index da7e5e7bfe..cfc3dfa4e4 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -5906,11 +5906,15 @@ function createWrappedSyncConfig< let sourceSession = 0 // Upstream reports wait for persisted startup, as markReady does. - const afterStartup = (report: () => void) => { + // Readiness and reports reach core in the order the adapter made them, + // after startup and after the sync function returned its result. + let adapterCalls = Promise.resolve() + const afterStartup = (call: () => void | Promise) => { if (startupState.cleanedUp || getTerminalFailure()) return - void (fullStartPromise ?? runtime.ensureStarted()) + adapterCalls = adapterCalls + .then(() => fullStartPromise ?? runtime.ensureStarted()) .then(() => { - if (!isCleanedUp() && !getTerminalFailure()) report() + if (!isCleanedUp() && !getTerminalFailure()) return call() }) .catch(() => undefined) } @@ -5920,23 +5924,18 @@ function createWrappedSyncConfig< reportError: (error: unknown) => afterStartup(() => params.reportError(error)), reportHealthy: () => afterStartup(params.reportHealthy), - markReady: () => { - if (startupState.cleanedUp || getTerminalFailure()) return - void (fullStartPromise ?? runtime.ensureStarted()) - .then(async () => { - if (isCleanedUp() || getTerminalFailure()) return - await sourceResultAssigned - try { - await sourceResultPromise - } catch (error) { - reportUpstreamError(error) - return - } - if (isCleanedUp() || getTerminalFailure()) return - params.markReady() - }) - .catch(() => undefined) - }, + markReady: () => + afterStartup(async () => { + await sourceResultAssigned + try { + await sourceResultPromise + } catch (error) { + reportUpstreamError(error) + return + } + if (isCleanedUp() || getTerminalFailure()) return + params.markReady() + }), begin: () => { if (startupState.cleanedUp) return undefined const terminalFailure = getTerminalFailure() diff --git a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts index 2f6790cca0..a80faf419c 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts @@ -57,7 +57,7 @@ import type { SyncConfig } from '@tanstack/db' type Row = { id: string; value: string } type Restore = `rows` | `empty` | `failed` -type Remote = `ready` | `fail` +type Remote = `ready` | `ready-report` | `fail` type Event = | { kind: `restore`; outcome: Restore } | { kind: `remote`; outcome: Remote } @@ -68,6 +68,7 @@ const DISK_ROW: Row = { id: `disk`, value: `restored` } const REMOTE_ROW: Row = { id: `remote`, value: `synced` } const RESTORE_FAILURE = `restore failed` const REMOTE_FAILURE = `remote gave up` +const REMOTE_REPORT = `remote retrying` // --------------------------------------------------------------------------- // Independent model @@ -76,6 +77,7 @@ const REMOTE_FAILURE = `remote gave up` type Expected = { status: `loading` | `ready` | `error` | `crashed` error: string | null + failureReason: string | null rows: Array } @@ -86,6 +88,7 @@ function expected(events: ReadonlyArray): Array { let remoteFailed = false let waitingRemote: Remote | undefined let error: string | null = null + let failureReason: string | null = null const rows = new Set() // Restored rows may open the Collection once the network had its chance. @@ -97,9 +100,12 @@ function expected(events: ReadonlyArray): Array { ) status = `ready` } - const applyRemoteReady = () => { + // The remote's calls apply in the order it made them: a report right after + // `markReady()` lands on the ready Collection. + const applyRemoteReady = (report: boolean) => { rows.add(REMOTE_ROW.id) if (status === `loading`) status = `ready` + if (report) failureReason = REMOTE_REPORT } const applyRemoteFailure = () => { openFromDisk() @@ -119,7 +125,8 @@ function expected(events: ReadonlyArray): Array { error = RESTORE_FAILURE } else { if (event.outcome === `rows`) rows.add(DISK_ROW.id) - if (waitingRemote === `ready`) applyRemoteReady() + if (waitingRemote === `ready`) applyRemoteReady(false) + else if (waitingRemote === `ready-report`) applyRemoteReady(true) else if (waitingRemote === `fail`) applyRemoteFailure() else openFromDisk() } @@ -129,13 +136,14 @@ function expected(events: ReadonlyArray): Array { } else { if (event.outcome === `fail`) remoteFailed = true if (restore === `pending`) waitingRemote = event.outcome - else if (event.outcome === `ready`) applyRemoteReady() - else applyRemoteFailure() + else if (event.outcome === `fail`) applyRemoteFailure() + else applyRemoteReady(event.outcome === `ready-report`) } } return { status, error, + failureReason: status === `crashed` ? null : failureReason, rows: status === `ready` ? [...rows].sort() : [], } }) @@ -156,7 +164,7 @@ function permutations(items: ReadonlyArray): Array> { const histories: Array> = [] for (const restore of [`rows`, `empty`, `failed`] as const) { - for (const remote of [`ready`, `fail`, undefined] as const) { + for (const remote of [`ready`, `ready-report`, `fail`, undefined] as const) { for (const deadline of [true, false]) { const events: Array = [{ kind: `restore`, outcome: restore }] if (remote) events.push({ kind: `remote`, outcome: remote }) @@ -234,11 +242,12 @@ function networkFirst(id: string, initialRender = true) { outcome === `failed` ? fixture.load.reject(new Error(RESTORE_FAILURE)) : fixture.load.resolve(outcome === `rows` ? [DISK_ROW] : []), - remoteReady: () => { + remoteReady: (report: boolean) => { remote!.begin() remote!.write({ type: `insert`, value: REMOTE_ROW }) remote!.commit() remote!.markReady() + if (report) remote!.reportError(new Error(REMOTE_REPORT)) }, remoteFail: () => remote!.fail(new Error(REMOTE_FAILURE)), } @@ -248,6 +257,7 @@ function observe(collection: ReturnType[`collection`]) { return { status: collection.status, error: collection.errors.error?.message ?? null, + failureReason: collection.errors.failureReason?.message ?? null, rows: collection.status === `ready` ? collection.toArray.map((row) => row.id).sort() @@ -282,8 +292,8 @@ describe(`persisted readiness oracle`, () => { if (event.kind === `restore`) source.restore(event.outcome) else if (event.kind === `deadline`) await vi.advanceTimersByTimeAsync(TIMEOUT) - else if (event.outcome === `ready`) source.remoteReady() - else source.remoteFail() + else if (event.outcome === `fail`) source.remoteFail() + else source.remoteReady(event.outcome === `ready-report`) await vi.advanceTimersByTimeAsync(0) const label = `after ${describe_(events.slice(0, index + 1))}` expect(observe(source.collection), label).toEqual(model[index]) @@ -309,7 +319,7 @@ describe(`persisted readiness oracle`, () => { source.restore(`rows`) await vi.advanceTimersByTimeAsync(TIMEOUT * 10) expect(source.collection.status).toBe(`loading`) - source.remoteReady() + source.remoteReady(false) await vi.advanceTimersByTimeAsync(0) expect(source.collection.status).toBe(`ready`) } finally { diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 109a6620cc..515235d757 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -429,5 +429,11 @@ "graphCheckedDuringRequest": "bw", "ownPrefixCount": "ce", "graphHasProcessedInput": "dk", - "windowIsInsideOwnPrefix": "gE" + "windowIsInsideOwnPrefix": "gE", + "failureReasonFromReload": "g5", + "failedBySource": "g6", + "latestFailed": "g7", + "loadOrders": "g8", + "endReload": "g9", + "loadOrder": "ha" } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index a81552c69a..e01ba38e9c 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -62,6 +62,8 @@ export class CollectionLifecycleManager< // A live query's request failures are kept apart, so settling a request // cannot clear the sync adapter's problem. private failureReason: CollectionErrorInfo | null = null + // A reload's new run reported the failure reason, so its first commit keeps it. + private failureReasonFromReload = false private stoppedError: CollectionErrorInfo | null = null // The cause of status `error` or `crashed`, kept so readers see the same // object until the sync run ends. @@ -327,6 +329,7 @@ export class CollectionLifecycleManager< const run = createDeferred() void run.promise.catch(() => undefined) this.reloadRun = run + this.failureReasonFromReload = false this.adapterStopped = false this.events.emitErrorsChange() return run.promise @@ -340,7 +343,7 @@ export class CollectionLifecycleManager< const run = this.reloadRun if (!run) return this.reloadRun = undefined - this.failureReason = null + if (!this.failureReasonFromReload) this.failureReason = null this.stoppedError = null this.requestErrors = new RequestErrors() if (this.status === `ready`) this.events.emitErrorsChange() @@ -378,9 +381,16 @@ export class CollectionLifecycleManager< /** The reload ended without a commit, for example by cleanup. */ public abandonReload(error: unknown): void { + if (!this.endReload(error)) return + if (this.status !== `cleaned-up`) this.events.emitErrorsChange() + } + + private endReload(error: unknown): boolean { const run = this.reloadRun + if (!run) return false this.reloadRun = undefined - run?.reject(error) + run.reject(error) + return true } /** @@ -391,8 +401,9 @@ export class CollectionLifecycleManager< public crash(error?: unknown): void { if (this.status === `crashed`) return this.validateStatusTransition(this.status, `crashed`) - // A crash ends a reload in flight with it. - this.abandonReload(error) + // A crash ends a reload in flight with it; its status change announces + // the tree. + this.endReload(error) this.statusError = describeProblem(error) this.setStatus(`crashed`) } @@ -423,6 +434,7 @@ export class CollectionLifecycleManager< this.assertAdapterActive(`reportError`) this.assertReady(`reportError`) this.failureReason = describeProblem(error) + this.failureReasonFromReload = this.reloading this.events.emitErrorsChange() } @@ -776,6 +788,27 @@ export class RequestErrors { } } +/** + * @internal Orders one source's loads: a success clears only the request + * errors of loads started before it, never a later load's failure. + */ +export class LoadOrder { + private started = 0 + private latestFailed = 0 + + track(result: unknown, settle: () => void): void { + const order = ++this.started + void Promise.resolve(result).then( + () => { + if (order > this.latestFailed) settle() + }, + () => { + this.latestFailed = Math.max(this.latestFailed, order) + }, + ) + } +} + /** @internal Error tree detail for a problem value. */ export function describeProblem(error: unknown): CollectionErrorInfo { const info = { diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 2531de7976..4925f2ed3d 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -879,6 +879,16 @@ export class CollectionStateManager< this.commitPendingTransactions() } + /** A retired sync run's open transaction can never commit. */ + discardOpenSyncTransactions(): void { + const committed = this.pendingSyncedTransactions.filter( + (transaction) => transaction.committed, + ) + if (committed.length === this.pendingSyncedTransactions.length) return + this.pendingSyncedTransactions = committed + this.refreshPendingSyncedProjection() + } + /** Whether the newest sync transaction is still open for writes. */ hasOpenSyncTransaction(): boolean { return this.pendingSyncedTransactions.at(-1)?.committed === false diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index e811241d0f..d5a58708cf 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -222,8 +222,9 @@ export class CollectionSyncManager< } const reload = this.lifecycle.beginReload() const syncCallbackEpoch = ++this.syncCallbackEpoch + // A retired run whose cleanup fails broke the adapter contract. const retired = this.retireSyncRun() - void retired.catch((error: unknown) => this.lifecycle.abandonReload(error)) + void retired.catch((error: unknown) => this.lifecycle.crash(error)) // Subscriptions detach demand from the retired run; the reload's // truncate replays it through the new run's loader. this.lifecycle.retireSubscriptionDemand() @@ -1109,6 +1110,7 @@ export class CollectionSyncManager< */ private retireSyncRun(): Promise { this.syncRunGeneration++ + this.state.discardOpenSyncTransactions() const cleanup = this.syncCleanupFn this.syncCleanupFn = null this.syncLoadSubsetFn = null diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 75cda6cae5..d7ac6af280 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -679,8 +679,9 @@ class LiveQueryObserverImpl< notify(undefined, status) }) const errorsUnsub = collection.on(`errors:change`, () => { - if (errorsOf() === publishedErrors) return - publishedErrors = errorsOf() + const errors = errorsOf() + if (errors === publishedErrors) return + publishedErrors = errors // No rows or status changed, so only this marks the snapshot stale. this.snapshotDirty = true notify(undefined, collection.status) diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 542ed34272..a14ee2a6b4 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -6,7 +6,7 @@ import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' -import { RequestErrors } from '../collection/lifecycle.js' +import { LoadOrder, RequestErrors } from '../collection/lifecycle.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -482,6 +482,7 @@ class EffectPipelineRunner { // a sync problem does not dispose the Effect. private reachedReady = false private readonly requests = new RequestErrors() + private readonly loadOrders = new Map() private reportedTree: ErrorTreeNode | undefined private readonly onErrorsChange: ((errors: ErrorTreeNode) => void) | undefined // This Effect as its sources' `subscribers` list it. @@ -1117,10 +1118,9 @@ class EffectPipelineRunner { /** A load that later succeeds clears the source's request error. */ private settleWhenLoaded(sourceId: string, result: unknown): void { - void Promise.resolve(result).then( - () => this.settleLoad(sourceId), - () => {}, - ) + let order = this.loadOrders.get(sourceId) + if (!order) this.loadOrders.set(sourceId, (order = new LoadOrder())) + order.track(result, () => this.settleLoad(sourceId)) } private settleLoad(sourceId: string): void { diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index f6a1ef230b..2553645890 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -15,6 +15,7 @@ import { normalizeError } from '../../utils/error.js' import { createSourceRecord } from '../../utils/source-record.js' import { codedMessage, devBuild } from '../../error-message.js' import { collectSourceRefs } from '../ir.js' +import { findProblems } from '../../collection/error-tree.js' import { CollectionSubscriber } from './collection-subscriber.js' import { getCollectionBuilder } from './collection-registry.js' import { LIVE_QUERY_INTERNAL } from './internal.js' @@ -124,6 +125,8 @@ export class CollectionConfigBuilder< // Error state tracking private isInErrorState = false private fatalQueryError = false + // A source's failed first load, not this query's own, ended the first load. + private failedBySource = false private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions @@ -131,19 +134,22 @@ export class CollectionConfigBuilder< private dependentEntry: CollectionDependent | undefined /** - * Reload each source with an error, then this query if its own first load - * failed. A source's reload keeps this query's status until it commits. + * Reload each source with an error anywhere in its tree, then this query if + * its own first load failed. A nested live query passes the reload down the + * same way. A source's reload keeps this query's status until it commits. */ async reload(): Promise { + const query = this.liveQueryCollection + // A crashed query rejects without touching its sources. + if (query?.status === `crashed`) return query._sync.reload() const failed = this.collectionSources .map(({ collection }) => collection) .filter( (collection, index, all) => all.indexOf(collection) === index && - (collection.status === `error` || collection.errors.error !== null), + findProblems(collection.errors).some((node) => node.error !== null), ) await Promise.all(failed.map((collection) => collection.reload())) - const query = this.liveQueryCollection if (query?.status === `error`) await query._sync.reload() } @@ -835,6 +841,7 @@ export class CollectionConfigBuilder< // Reset error state from any previous sync run so a restarted sync can become ready again. this.isInErrorState = false this.fatalQueryError = false + this.failedBySource = false this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config @@ -917,6 +924,7 @@ export class CollectionConfigBuilder< this.settledWindow = this.initialWindow this.isInErrorState = false this.fatalQueryError = false + this.failedBySource = false // Reset caches so a fresh graph/pipeline is compiled on next start // This avoids reusing a finalized D2 graph across GC restarts @@ -1238,6 +1246,7 @@ export class CollectionConfigBuilder< // node: the query keeps its status and rows. if (status === `error`) { if (this.liveQueryCollection?.status === `ready`) return + this.failedBySource = true this.failQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Source collection '${collectionId}' failed its first load` @@ -1259,12 +1268,14 @@ export class CollectionConfigBuilder< // source reloaded. It shows `loading` until that run is ready. if ( status === `ready` && + this.failedBySource && this.liveQueryCollection?.status === `error` && this.collectionSources.every( ({ collection }) => collection.status !== `error` && collection.status !== `crashed`, ) ) { + this.failedBySource = false void this.liveQueryCollection._sync .reload({ showLoading: true }) .catch(() => undefined) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index d6355a6839..7cf155a249 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -1,4 +1,5 @@ import { normalizeExpressionPaths } from '../compiler/expressions.js' +import { LoadOrder } from '../../collection/lifecycle.js' import { OrderedSourceLoader } from './ordered-source-loader.js' import { computeSubscriptionOrderByHints, @@ -30,6 +31,7 @@ export class CollectionSubscriber< TContext extends Context, TResult extends object = GetResult, > { + private readonly loadOrder = new LoadOrder() // Track deferred promises for subscription loading states private subscriptionLoadingPromises = new Map< CollectionSubscription, @@ -81,9 +83,8 @@ export class CollectionSubscriber< // can break under microtask timing (e.g., queueMicrotask in TanStack Query). const trackLoadResult = (result: LoadSubsetRequestResult) => { // A later successful load clears this source's request error. - void Promise.resolve(result).then( - () => this.collectionConfigBuilder.settleRootLoad(this.sourceId), - () => {}, + this.loadOrder.track(result, () => + this.collectionConfigBuilder.settleRootLoad(this.sourceId), ) if (result instanceof Promise) { // Defer the tracked rejection by one microtask so the subscription's diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index d269ebed7c..593e8b3c18 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -65,9 +65,14 @@ * A reload keeps the rows, status, and shown problem, and marks the node * `reloading`, until the new run's first commit (the next `ready`) replaces * the rows and clears the node, or its `fail` shows the new error. A - * reload of a crashed Collection rejects. A query whose first load failed + * reload of a crashed Collection rejects. `reload-cleanup-fails` reloads while + * the retired run's cleanup rejects: that breaks the adapter contract, so the + * Collection crashes and the reload ends with it, unless a reload is already in + * flight and nothing is retired. A query whose first load failed * starts again once no source has a failure, `loading` with `reloading` - * until it is ready. `restart` cleans up the + * until it is ready. `reload-query` calls the observed query's `reload()`, + * which reloads every Collection below it that shows an error, through a + * nested live query. `restart` cleans up the * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw * for `markReady()` after `fail()`, for any report after `fail()`, and for a @@ -116,6 +121,7 @@ import { eq } from '../src/query/builder/functions.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import { RequestErrors } from '../src/collection/lifecycle.js' import { BasicIndex } from '../src/indexes/basic-index.js' +import { createDeferred } from '../src/deferred.js' import { oraclePropertyOptions } from './oracle-config.js' import { flushPromises } from './utils.js' import type { Collection } from '../src/collection/index.js' @@ -133,10 +139,18 @@ type Shape = `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` type Step = | { - kind: `ready` | `fail` | `report` | `healthy` | `crash` | `reload` + kind: + | `ready` + | `fail` + | `report` + | `healthy` + | `crash` + | `reload` + | `reload-cleanup-fails` source: SourceName } | { kind: `restart` } + | { kind: `reload-query` } // --------------------------------------------------------------------------- // Independent model @@ -153,7 +167,13 @@ type GateModel = { gate: Gate; cause: string | null; reloading: boolean } // The node shows `stopped` (from fail() after readiness) over `failure` // (from reportError()); a reload's new run can report while the previous // run's error still shows. -type SourceModel = GateModel & { failure: Problem; stopped: Problem } +// `failureFromReload`: the new run reported `failure` itself, so its first +// commit keeps it. +type SourceModel = GateModel & { + failure: Problem + stopped: Problem + failureFromReload: boolean +} type Model = { sources: Record query: GateModel @@ -179,6 +199,7 @@ function freshModel(): Model { reloading: false, failure: null, stopped: null, + failureFromReload: false, }) return { sources: { A: source(), B: source() }, @@ -253,6 +274,20 @@ function applyModel( Object.assign(model, freshModel()) return `ok` } + if (step.kind === `reload-query`) { + // A pooled view has no reload of its own. + if (shape === `pooled-nested`) return `ok` + if (model.query.gate === `crashed`) return `throws` + // The query reloads every Collection below it that shows an error, + // through any nested live query; a reload in flight keeps its run. + for (const name of shapeSources[shape]) { + const source = model.sources[name] + if (source.gate === `error` || source.stopped !== null) + source.reloading = true + } + deriveQueries(shape, model) + return `ok` + } // A step on a source this shape does not read has no effect. if (!shapeSources[shape].includes(step.source)) return `ok` const source = model.sources[step.source] @@ -263,16 +298,20 @@ function applyModel( !source.reloading && (source.gate === `error` || source.stopped !== null) if (step.kind !== `crash` && source.gate === `crashed`) { deriveQueries(shape, model) - return step.kind === `reload` ? `throws` : outcome + return step.kind === `reload` || step.kind === `reload-cleanup-fails` + ? `throws` + : outcome } switch (step.kind) { case `ready`: if (source.reloading) { - // The new run's first commit replaces the rows and clears the node. + // The new run's first commit replaces the rows and clears the + // retired run's problems from the node. source.reloading = false source.gate = `ready` source.cause = null - source.failure = null + if (!source.failureFromReload) source.failure = null + source.failureFromReload = false source.stopped = null } else if (source.gate === `error`) outcome = `bad-transition` else if (stopped) outcome = `throws` @@ -282,6 +321,17 @@ function applyModel( // Everything visible stays until the new run commits or fails. source.reloading = true break + case `reload-cleanup-fails`: + // A reload in flight returns its promise and retires nothing. + if (source.reloading) break + // The retired run's cleanup rejecting breaks the adapter contract: + // the Collection crashes and the reload ends with it. + source.gate = `crashed` + source.cause = `${step.source} cleanup failed` + source.failure = null + source.stopped = null + source.reloading = false + break case `fail`: // Before Collection readiness the first load fails; after it, the // rows stay and the node shows the error. @@ -301,15 +351,20 @@ function applyModel( break case `report`: if (stopped || source.gate !== `ready`) outcome = `throws` - else + else { source.failure = { message: `${step.source} problem`, serial: ++model.serial, } + source.failureFromReload = source.reloading + } break case `healthy`: if (stopped || source.gate !== `ready`) outcome = `throws` - else source.failure = null + else { + source.failure = null + source.failureFromReload = false + } break case `crash`: // From any active status, for good. @@ -407,7 +462,8 @@ function expectedProblems(node: ExpectedNode): Array { function problemOf(node: ErrorTreeNode): ProblemSummary { return [ node.alias, - (node.error ?? node.failureReason)?.message ?? `instances`, + (node.error ?? node.failureReason)?.message.replace(cleanupPrefix, ``) ?? + `instances`, ] } @@ -461,6 +517,7 @@ function expectedTree( function createSource(name: SourceName) { let params: SyncParams | undefined + let failNextCleanup = false const collection = createCollection({ id: `health-oracle-${name}-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, @@ -469,11 +526,19 @@ function createSource(name: SourceName) { sync: { sync: (next) => { params = next + return () => { + if (!failNextCleanup) return + failNextCleanup = false + return Promise.reject(new Error(`${name} cleanup failed`)) + } }, }, }) return { collection, + failNextCleanup: (armed = true) => { + failNextCleanup = armed + }, get params(): SyncParams { if (!params) throw new Error(`source ${name} has not started`) return params @@ -574,9 +639,13 @@ type PooledView = Subject[`query`] & { } // The public tree, projected onto the fields the model owns. +// A failed adapter cleanup reaches the node as a SyncCleanupError, whose +// message names the Collection; the model knows only the adapter's message. +const cleanupPrefix = /^Collection ".*" sync cleanup function threw an error: / + function project(node: ErrorTreeNode): ExpectedNode { const problem = (info: ErrorTreeNode[`error`]): ExpectedProblem => - info ? { message: info.message } : null + info ? { message: info.message.replace(cleanupPrefix, ``) } : null return { alias: node.alias, kind: node.kind, @@ -679,7 +748,13 @@ async function runHistory( events = [] let thrown: unknown try { - if (step.kind === `restart`) { + if (step.kind === `reload-query`) { + const reload = (query as { reload?: () => Promise }).reload?.() + if (query.status === `crashed`) + thrown = await reload?.catch((e: unknown) => e) + else void reload?.catch(() => undefined) + await flushPromises() + } else if (step.kind === `restart`) { await query.cleanup() await inner?.cleanup({ dependents: true }) await Promise.all([ @@ -696,6 +771,16 @@ async function runHistory( if (params.collection.status === `crashed`) thrown = await reload.catch((e: unknown) => e) else void reload.catch(() => undefined) + } else if (step.kind === `reload-cleanup-fails`) { + sources[step.source].failNextCleanup() + const reload = params.collection.reload() + if (params.collection.status === `crashed`) + thrown = await reload.catch((e: unknown) => e) + else { + void reload.catch(() => undefined) + await flushPromises() + } + sources[step.source].failNextCleanup(false) } else if (step.kind === `ready`) { // A reload's new run commits its row, as a first load does. if ( @@ -836,6 +921,27 @@ const namedHistories: Record> = { on(`ready`, `A`), on(`ready`, `B`), ], + 'a query reload reaches every Collection below it that stopped': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`fail`, `A`), + { kind: `reload-query` }, + on(`ready`, `A`), + ], + 'a query reload restarts a first load that failed below it': [ + on(`fail`, `A`), + { kind: `reload-query` }, + on(`ready`, `A`), + on(`ready`, `B`), + ], + "a reload's first commit keeps a failure reason its new run reported": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + on(`reload`, `A`), + on(`report`, `A`), + on(`ready`, `A`), + ], 'an initial error is final for its sync run': [ on(`fail`, `A`), on(`ready`, `A`), @@ -905,6 +1011,7 @@ describe(`source failure and health oracle`, () => { const stepArb: fc.Arbitrary = fc.oneof( { weight: 1, arbitrary: fc.constant(restart) }, + { weight: 1, arbitrary: fc.constant({ kind: `reload-query` } as const) }, { weight: 6, arbitrary: fc @@ -916,6 +1023,7 @@ describe(`source failure and health oracle`, () => { `healthy` as const, `crash` as const, `reload` as const, + `reload-cleanup-fails` as const, ), fc.constantFrom(`A` as const, `B` as const), ) @@ -943,6 +1051,43 @@ describe(`source failure and health oracle`, () => { * needing it; a root source's error clears when a later load of that source * settles. Before initial-query readiness, the same failure fails the query's sync run. */ +// --------------------------------------------------------------------------- +// Reload invariant witness +// --------------------------------------------------------------------------- + +// A reload retires its sync run, and the retired run's `commit()` can no +// longer reach a transaction it left open. That transaction has no public +// observation (its writes never apply), so this witness checks the +// invariant directly: after the reload no transaction stays open, and the +// new run's own commit decides the row. +describe(`reload invariant`, () => { + it(`drops the retired run's open sync transaction`, async () => { + const source = createSource(`A`) + source.collection.startSyncImmediate() + const first = source.params + first.begin() + first.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + first.commit() + first.markReady() + first.begin() + first.write({ type: `insert`, value: { id: 2, parentId: 0 } }) + const reload = source.collection.reload() + const next = source.params + next.begin() + next.write({ type: `insert`, value: { id: 2, parentId: 2 } }) + next.commit() + next.markReady() + await reload + expect( + source.collection._state.pendingSyncedTransactions.filter( + (transaction) => !transaction.committed, + ), + ).toEqual([]) + expect(source.collection.get(2)).toMatchObject({ id: 2, parentId: 2 }) + await source.collection.cleanup() + }) +}) + describe(`request errors`, () => { function createRequestHistory() { let parentParams!: SyncParams @@ -1072,6 +1217,66 @@ describe(`request errors`, () => { })) }) +// A query restarts by itself only when the source whose failure ended its +// first load becomes ready. A query whose own load failed waits for +// `reload()`: another source becoming ready does not restart it, and its +// failed load is not retried. The main grammar fails only sources, so this +// lane supplies the query's own failure. +describe(`a query's own failed first load`, () => { + it(`does not restart when an unrelated source becomes ready`, async () => { + let loads = 0 + const children = createCollection({ + id: `health-own-children-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => { + loads++ + return new Promise((_, reject) => + setTimeout(() => reject(new Error(`child load failed`)), 0), + ) + }, + } + }, + }, + }) + const parents = createSource(`B`) + parents.collection.startSyncImmediate() + const query = createLiveQueryCollection((q) => + q + .from({ child: children }) + .where(({ child }) => eq(child.parentId, 1)) + .join({ parent: parents.collection }, ({ child, parent }) => + eq(child.parentId, parent.id), + ), + ) + const consoleError = console.error + console.error = () => {} + try { + void query.preload().catch(() => undefined) + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(query.status).toBe(`error`) + expect(query.errors.error).toMatchObject({ message: `child load failed` }) + const loadsAtFailure = loads + parents.params.markReady() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(query.status).toBe(`error`) + expect(loads).toBe(loadsAtFailure) + } finally { + console.error = consoleError + await query.cleanup() + await parents.collection.cleanup() + await children.cleanup() + } + }) +}) + describe(`root request errors`, () => { it(`a failed root load after initial-query readiness is a request error that clears when a later load settles`, async () => { const outcomes: Array<`ok` | `fail`> = [] @@ -1125,6 +1330,112 @@ describe(`root request errors`, () => { }) }) +// Two root loads of one source can overlap: a window move, then a refill +// after a visible row is deleted. A success clears only the request errors of +// loads started before it. The model: a failure stays shown until a load that +// started after it succeeds after it failed. It shows the latest failure. +type OverlapHistory = { + outcomes: readonly [`ok` | `fail`, `ok` | `fail`] + // Which load settles first: the window move (0) or the refill (1). + first: 0 | 1 +} +function expectedOverlap({ outcomes, first }: OverlapHistory) { + const order = first === 0 ? ([0, 1] as const) : ([1, 0] as const) + const failed = new Set() + let shown: string | null = null + return order.map((load) => { + if (outcomes[load] === `fail`) { + failed.add(load) + shown = `load ${load} failed` + } else { + for (const earlier of [...failed]) + if (earlier < load) failed.delete(earlier) + if (failed.size === 0) shown = null + } + return shown + }) +} + +describe(`overlapping root loads`, () => { + const histories: Array = [] + for (const a of [`ok`, `fail`] as const) + for (const b of [`ok`, `fail`] as const) + for (const first of [0, 1] as const) + histories.push({ outcomes: [a, b], first }) + + it.each(histories)( + `outcomes $outcomes, load $first settles first`, + async (history) => { + const loads: Array>> = [] + let deferLoads = false + let params!: SyncParams + const source = createCollection({ + id: `health-overlap-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (next) => { + params = next + next.markReady() + return { + loadSubset: () => { + if (!deferLoads) return Promise.resolve() + const load = createDeferred() + loads.push(load) + return load.promise + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.id) + .limit(2), + ) + const consoleError = console.error + console.error = () => {} + try { + await query.preload() + params.begin() + for (const id of [1, 2, 3, 4]) + params.write({ type: `insert`, value: { id, parentId: id } }) + params.commit() + await flushPromises() + deferLoads = true + void Promise.resolve( + query.utils.setWindow({ offset: 2, limit: 2 }), + ).catch(() => undefined) + await flushPromises() + params.begin() + params.write({ type: `delete`, value: { id: 3, parentId: 3 } }) + params.commit() + await flushPromises() + // Premise: both loads are in flight at once. + expect(loads).toHaveLength(2) + const model = expectedOverlap(history) + const order = history.first === 0 ? [0, 1] : [1, 0] + for (const [step, load] of order.entries()) { + if (history.outcomes[load] === `ok`) loads[load]!.resolve() + else loads[load]!.reject(new Error(`load ${load} failed`)) + await flushPromises() + expect( + query.errors.error?.message ?? null, + `after load ${load} settles`, + ).toBe(model[step]) + expect(query.status).toBe(`ready`) + } + } finally { + console.error = consoleError + await query.cleanup() + await source.cleanup() + } + }, + ) +}) + // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- From 8a2e2a5d06ce5e2ee18ef2c0e157beff0e9fa421 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 07:59:09 -0600 Subject: [PATCH 066/106] chore: describe recursive reload and reload crash rules in the changeset Co-authored-by: Isaac --- .changeset/source-health-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 2d594d6a2d..a38f30d7e4 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -17,7 +17,7 @@ Collections now answer two questions separately: can the data be shown (`status` - `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It is permanent for the instance and reaches every live query and Effect above it. `error` now means only "the first load failed". - Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. -- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads the failed sources, then the query. It rejects on a `crashed` Collection. +- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` restarts by itself only when the source whose failed first load caused it becomes ready. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. - Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. From 11fca7bc08823e22bfb86c65b1524fcfc6de462d Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 08:25:18 -0600 Subject: [PATCH 067/106] docs: fix the errors:change example, glossary status list, and a list break Address CodeRabbit: the errors:change payload carries the collection, not the tree; the glossary's Collection status list includes crashed and the post-readiness rule holds until cleanup; a paragraph after the refetch options no longer renders inside the last bullet. Co-authored-by: Isaac --- docs/collections/query-collection.md | 1 + docs/contributing/glossary.md | 5 +++-- docs/guides/error-handling.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 29c1e3b126..da2d1a5f09 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -682,6 +682,7 @@ The collection provides these utility methods via `collection.utils`: - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) - Returns the tracked Queries' `QueryObserverResult` values in tracked-key order - Preserves an `undefined` slot if a tracked Query is removed while the refetch starts + Failures appear in the error tree. Before the first successful result, a failed fetch fails the first load (`status: 'error'`). After the Collection is ready, a failed fetch or result application for any Query it observes sets the diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 5330917f8f..1039a370fd 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -30,7 +30,7 @@ production queues, caches, or semantic helpers merely to share their names. | optimistic state | The rows an optimistic transaction overlays on the applied synced rows while its mutation function runs. It drops when the mutation function settles, whether it fulfills or rejects. | Retained snapshot or accepted local snapshot. | | accepted sync transaction | A committed sync transaction that core, or a wrapping sync after its durable step, has taken. It always applies, in commit order. While an optimistic transaction is persisting it is held, and it applies in the publication that drops that transaction's optimistic state. Handler-facing writes and Collection readiness wait for acceptance; commit receipts and subset loads wait for visibility. | Visible or applied sync transaction. | | held optimistic row | A successful transaction's optimistic row kept only while a queued sync transaction touches its key, so the drop and that transaction publish together. Its source attribution follows the key-and-timing rules in [VirtualOrigin](../reference/type-aliases/VirtualOrigin.md), not source-client identity. | Retained or accepted snapshot. | -| Collection status | One public Collection lifecycle value: `idle`, `loading`, `ready`, `error`, or `cleaned-up`. | Subscription status. | +| Collection status | One public Collection lifecycle value: `idle`, `loading`, `ready`, `error`, `crashed`, or `cleaned-up`. | Subscription status. | | subscription status | One subscription value: `ready` or `loadingSubset`. | Collection status. | | subscription | A consumer of Collection changes with its own subset demands and lifecycle. | Collection, query, or transport. | | sync adapter | The code supplied through Collection sync configuration that starts a sync run and translates between TanStack DB and a provider. | Provider or source Collection. | @@ -62,7 +62,8 @@ and a provider-qualified `provider session` for a remote lifecycle. Collection status and the error tree are separate axes. Status says whether the data can be shown; the error tree says whether the data on screen is still kept -current. After Collection readiness, only a crash changes status. +current. After Collection readiness in a sync run, only a crash changes status +until cleanup ends the run. | Term | Meaning | Do not use as a synonym | | --- | --- | --- | diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index e75fbd53c5..b5f0557a46 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -329,7 +329,7 @@ does not need a live query: ```ts todos.status // 'loading' | 'ready' | 'error' | 'crashed' | ... todos.errors // one node: { status, failureReason, error, reloading, ... } -todos.on("errors:change", ({ errors }) => report(errors)) +todos.on("errors:change", ({ collection }) => report(collection.errors)) await todos.reload() ``` From 26946b3f15d828f5d5d236eca17538e6bf48fda0 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 09:42:32 -0600 Subject: [PATCH 068/106] fix: settle reloads from error in every sync adapter; keep reload rows A reload runs `sync` again while the Collection still shows the previous run's status. Five adapters decided between markReady() and fail() by reading `collection.status`, so a reload of a Collection whose first load failed never settled: query-db, Electric, RxDB, PowerSync, and TrailBase. Each now tracks its own sync run's readiness. A focused witness per adapter (TrailBase's in its lifecycle oracle) failed before the change. Core: - While a reload's truncate is armed, acceptedSyncedEntries() is empty, so an adapter that diffs against it writes the full snapshot. A query-db reload from ready emptied the Collection before this. - A commit canceled by its signal during a reload re-arms the reload's own truncate for the run's next commit instead of abandoning the reload mid-run (old and new rows mixed before). - A request error clears only when a load started after the failure succeeds; the Effect's lazy join loads now go through the same rule. Oracle: source-health gains a `cancel` step, named histories, an overlapping-Effect-load lane, a later-load clearing check, and diffing and cancel reload witnesses. Docs: the adapter guides keep run readiness in `sync`, and their examples no longer read collection.status. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- docs/guides/collection-options-creator.md | 12 +- docs/guides/error-handling.md | 3 + packages/db/mangle-cache.json | 7 +- .../db/skills/db-core/custom-adapter/SKILL.md | 9 +- packages/db/src/collection/lifecycle.ts | 12 +- packages/db/src/collection/state.ts | 4 + packages/db/src/collection/sync.ts | 42 +++- packages/db/src/query/effect.ts | 6 +- .../db/tests/source-health-oracle.test.ts | 225 ++++++++++++++++-- .../electric-db-collection/src/electric.ts | 24 +- .../tests/electric.test.ts | 38 +++ .../powersync-db-collection/src/powersync.ts | 34 +-- .../tests/load-hooks.test.ts | 35 +++ packages/query-db-collection/src/query.ts | 44 ++-- .../query-db-collection/tests/query.test.ts | 63 +++++ packages/rxdb-db-collection/src/rxdb.ts | 8 +- .../rxdb-db-collection/tests/rxdb.test.ts | 42 ++++ .../trailbase-db-collection/src/trailbase.ts | 13 +- .../trailbase-db-collection/tests/ORACLE.md | 12 +- .../tests/lifecycle-oracle.property.test.ts | 60 ++++- 21 files changed, 598 insertions(+), 97 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 264bbe93d8..22c159a17d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a success clears only failures of loads started before it), a query's own failed first load not restarting when an unrelated source becomes ready, a reload invariant witness that drops the retired run's open sync transaction, and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Open: the Effect's overlapping-load ordering has no witness (it shares `LoadOrder` with the live query); an abandoned reload's `errors:change` without a crash (a rejected truncate receipt) has no witness; on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads and overlapping Effect join loads (a request error clears only when a load started after the failure succeeds; 16 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. Open: the Effect keeps lazy-demand request errors per source rather than per demand; on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index b6a71076a8..e26cee1ee9 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -79,7 +79,7 @@ function for proper garbage collection: ```typescript const sync: SyncConfig['sync'] = (params) => { - const { begin, write, commit, markReady, fail, collection } = params + const { begin, write, commit, markReady, fail } = params // 1. Initialize connection to your sync engine const connection = initializeConnection(config) @@ -147,9 +147,9 @@ const sync: SyncConfig['sync'] = (params) => { } catch (error) { if (initialSyncAbort.signal.aborted) return console.error('Initial sync failed:', error) - // No usable initial snapshot exists, and this example does not retry. - // After readiness, report failures with reportError() instead. - if (collection.status === 'loading') fail(error) + // No usable initial snapshot exists in this run, and this example does + // not retry. After readiness, report failures with reportError() instead. + fail(error) } } @@ -190,6 +190,10 @@ still kept up to date. Follow these rules so both stay truthful: 3. After the first `markReady()`, call `reportError(error)` for a problem while you keep trying, and `reportHealthy()` when sync works again. Never discard rows because sync failed. +4. Track the run's readiness in your `sync` function, as `ready` does below. + A reload calls `sync` again while `collection.status` still shows the + previous run, so status cannot tell you whether this run is ready. Write a + full snapshot on the run's first commit: a reload replaces every row then. ```ts sync: ({ begin, write, commit, markReady, fail, reportError, reportHealthy }) => { diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index b5f0557a46..76ecb5207e 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -527,6 +527,9 @@ sync: ({ begin, write, commit, markReady, reportError, fail, reportHealthy }) => nothing, so you can call it after every commit. - An `error` from `fail` clears only through `reload()`, which calls your `sync` function again for a new run. +- Keep the run's readiness in your `sync` function, as `ready` above does. + Do not read `collection.status` to learn whether this run is ready: during + a reload it still shows the previous run's status. ### Migrating diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 515235d757..7f80c47257 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -415,7 +415,6 @@ "crashQuery": "gN", "failQuery": "gW", "assertReady": "gX", - "reloadTruncatePending": "gY", "reloadRun": "gZ", "adapterStopped": "g_", "recovering": "g$", @@ -432,8 +431,10 @@ "windowIsInsideOwnPrefix": "gE", "failureReasonFromReload": "g5", "failedBySource": "g6", - "latestFailed": "g7", "loadOrders": "g8", "endReload": "g9", - "loadOrder": "ha" + "loadOrder": "ha", + "reloadTruncateTransaction": "gY", + "cancelSyncTransaction": "g7", + "startedBeforeFailure": "hb" } diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index 04c23a909f..fa858fac67 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -55,7 +55,7 @@ function myBackendCollectionOptions(config: { return { getKey: config.getKey, sync: { - sync: ({ begin, write, commit, markReady, fail, collection }) => { + sync: ({ begin, write, commit, markReady, fail }) => { let isInitialSyncComplete = false const bufferedEvents: Array> = [] const initialSyncAbort = new AbortController() @@ -95,9 +95,10 @@ function myBackendCollectionOptions(config: { .catch((error) => { if (initialSyncAbort.signal.aborted) return console.error('Initial sync failed:', error) - // Give up only before readiness. After it, report the problem - // with reportError() and keep the rows. - if (collection.status === 'loading') fail(error) + // This catch runs only before this run's markReady(). After it, + // report problems with reportError() and keep the rows. Do not + // read collection.status: a reload keeps the previous status. + fail(error) }) // 5. Return cleanup function diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index e01ba38e9c..c6e78b7bb5 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -789,21 +789,23 @@ export class RequestErrors { } /** - * @internal Orders one source's loads: a success clears only the request - * errors of loads started before it, never a later load's failure. + * @internal Orders one source's loads: a request error clears only when a + * load started after the failure succeeds. A load already in flight cannot + * cover the data whose request failed. */ export class LoadOrder { private started = 0 - private latestFailed = 0 + // Loads started before the latest failure; their success clears nothing. + private startedBeforeFailure = 0 track(result: unknown, settle: () => void): void { const order = ++this.started void Promise.resolve(result).then( () => { - if (order > this.latestFailed) settle() + if (order > this.startedBeforeFailure) settle() }, () => { - this.latestFailed = Math.max(this.latestFailed, order) + this.startedBeforeFailure = this.started }, ) } diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 4925f2ed3d..ce12ac2ccd 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -115,6 +115,8 @@ export class CollectionStateManager< // Core state - make public for testing public transactions: SortedMap> + /** A reload armed its truncate for the new run's next transaction. */ + public reloadTruncatePending = false public pendingSyncedTransactions: Array< PendingSyncedTransaction > = [] @@ -903,6 +905,8 @@ export class CollectionStateManager< /** Every synced row once every accepted sync transaction applies. */ *acceptedSyncedEntries(): IterableIterator<[TKey, TOutput]> { + // A reload's next commit replaces every synced row, so it builds on none. + if (this.reloadTruncatePending) return const { states, truncated } = this.pendingSyncedProjection if (!truncated) { for (const entry of this.syncedData.entries()) { diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index d5a58708cf..4c4e2d6f2c 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -98,7 +98,8 @@ export class CollectionSyncManager< private syncRunGeneration = 0 private syncEntryActive = false // A reload's new sync run has not begun its first transaction yet. - private reloadTruncatePending = false + // The transaction that carries the reload's truncate, until it commits. + private reloadTruncateTransaction: object | undefined private pendingSyncEntryCleanup: | { tasks: Array> @@ -228,7 +229,7 @@ export class CollectionSyncManager< // Subscriptions detach demand from the retired run; the reload's // truncate replays it through the new run's loader. this.lifecycle.retireSubscriptionDemand() - this.reloadTruncatePending = true + this.state.reloadTruncatePending = true if (options.showLoading && status === `error`) { this.lifecycle.restartFromError() } @@ -269,7 +270,7 @@ export class CollectionSyncManager< applied, duplicateKeyError: (key) => this.createDuplicateKeyError(key), }) - if (this.reloadTruncatePending && this.lifecycle.reloading) + if (this.state.reloadTruncatePending && this.lifecycle.reloading) this.markReloadTruncate(applied) }, write: ( @@ -341,12 +342,12 @@ export class CollectionSyncManager< } if (signal?.aborted) { - this.state.cancelPendingSyncedTransaction(pendingTransaction) + this.cancelSyncTransaction(pendingTransaction) return pendingTransaction.applied.promise } if (pendingTransaction.invalidationError !== undefined) { - this.state.cancelPendingSyncedTransaction( + this.cancelSyncTransaction( pendingTransaction, pendingTransaction.invalidationError, ) @@ -366,7 +367,8 @@ export class CollectionSyncManager< if (!isCurrentSync()) return if (this.lifecycle.reloading) { // A reload that has not committed replaces the rows with none. - if (this.reloadTruncatePending) this.commitEmptyReloadTruncate() + if (this.state.reloadTruncatePending) + this.commitEmptyReloadTruncate() // A reload of a ready Collection completes when its commit // applies; one that starts from `loading` or `error` becomes // ready here, as a first load does. @@ -538,8 +540,9 @@ export class CollectionSyncManager< /** The reload's first transaction replaces every synced row. */ private markReloadTruncate(applied: { promise: Promise }): void { - this.reloadTruncatePending = false + this.state.reloadTruncatePending = false const transaction = this.state.pendingSyncedTransactions.at(-1)! + this.reloadTruncateTransaction = transaction transaction.truncate = true transaction.truncateMarkReady = false this.state.refreshPendingSyncedProjection() @@ -552,8 +555,29 @@ export class CollectionSyncManager< ) this.lifecycle.completeReload() }, - (error: unknown) => this.lifecycle.abandonReload(error), + (error: unknown) => { + // A canceled commit moved the truncate to the run's next commit; a + // reload ends only with its run. + if ( + syncRunGeneration !== this.syncRunGeneration || + !this.lifecycle.reloading + ) + this.lifecycle.abandonReload(error) + }, + ) + } + + /** A canceled commit never happened, so a reload's truncate moves on. */ + private cancelSyncTransaction( + transaction: (typeof this.state.pendingSyncedTransactions)[number], + reason?: Error, + ): void { + this.state.cancelPendingSyncedTransaction(transaction, reason) + if ( + transaction === this.reloadTruncateTransaction && + this.lifecycle.reloading ) + this.state.reloadTruncatePending = true } private commitEmptyReloadTruncate(): void { @@ -1157,7 +1181,7 @@ export class CollectionSyncManager< // before invoking adapter cleanup or allowing a new sync run to start. ++this.syncCallbackEpoch this.syncRunGeneration++ - this.reloadTruncatePending = false + this.state.reloadTruncatePending = false this.rejectPreload?.(new CollectionPreloadAbortedError()) const cleanup = this.syncCleanupFn this.syncCleanupFn = null diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index a14ee2a6b4..3f73b028e2 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -900,11 +900,10 @@ class EffectPipelineRunner { // Each segment reports its own failure through the subscription. Consume // the aggregate rejection so Promise.all does not create a second, // detached error channel. + this.settleWhenLoaded(sourceId, update.ready) void update.ready.then( () => { - if (this.disposed) return - this.settleLoad(sourceId) - this.scheduleGraphRun() + if (!this.disposed) this.scheduleGraphRun() }, () => {}, ) @@ -1124,6 +1123,7 @@ class EffectPipelineRunner { } private settleLoad(sourceId: string): void { + if (this.disposed) return if (this.requests.settle(sourceId)) this.reportErrors() } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 593e8b3c18..d3b7eefc54 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -68,7 +68,9 @@ * reload of a crashed Collection rejects. `reload-cleanup-fails` reloads while * the retired run's cleanup rejects: that breaks the adapter contract, so the * Collection crashes and the reload ends with it, unless a reload is already in - * flight and nothing is retired. A query whose first load failed + * flight and nothing is retired. `cancel` begins, writes, and commits with an + * aborted signal: nothing changes, and a reload's truncate waits for the run's + * next commit. A query whose first load failed * starts again once no source has a failure, `loading` with `reloading` * until it is ready. `reload-query` calls the observed query's `reload()`, * which reloads every Collection below it that shows an error, through a @@ -147,6 +149,7 @@ type Step = | `crash` | `reload` | `reload-cleanup-fails` + | `cancel` source: SourceName } | { kind: `restart` } @@ -321,6 +324,10 @@ function applyModel( // Everything visible stays until the new run commits or fails. source.reloading = true break + case `cancel`: + // A commit canceled by its signal never happened: nothing changes, + // and a reload's truncate waits for the run's next commit. + break case `reload-cleanup-fails`: // A reload in flight returns its promise and retires nothing. if (source.reloading) break @@ -795,6 +802,17 @@ async function runHistory( params.commit() } params.markReady() + } else if (step.kind === `cancel`) { + const canceled = new AbortController() + canceled.abort() + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + try { + params.commit(canceled.signal) + } catch { + // A canceled commit may throw its abort reason. + } + await flushPromises() } else if (step.kind === `fail`) { params.fail(new Error(`${step.source} gave up`)) } else if (step.kind === `healthy`) { @@ -942,6 +960,13 @@ const namedHistories: Record> = { on(`report`, `A`), on(`ready`, `A`), ], + "a canceled commit leaves a reload waiting for the run's next commit": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + on(`cancel`, `A`), + on(`ready`, `A`), + ], 'an initial error is final for its sync run': [ on(`fail`, `A`), on(`ready`, `A`), @@ -1024,6 +1049,7 @@ describe(`source failure and health oracle`, () => { `crash` as const, `reload` as const, `reload-cleanup-fails` as const, + `cancel` as const, ), fc.constantFrom(`A` as const, `B` as const), ) @@ -1061,6 +1087,62 @@ describe(`source failure and health oracle`, () => { // invariant directly: after the reload no transaction stays open, and the // new run's own commit decides the row. describe(`reload invariant`, () => { + // A reload's next commit replaces every synced row, so the baseline an + // adapter diffs against is empty until that commit. An adapter that writes + // only rows missing from the baseline, as Query Collections do, must still + // rebuild every row. + it(`gives a diffing adapter an empty baseline for the reload's commit`, async () => { + const source = createSource(`A`) + source.collection.startSyncImmediate() + const writeMissing = (rows: Array) => { + const params = source.params + const baseline = new Map(params.collection._state.acceptedSyncedEntries()) + params.begin() + for (const row of rows) + if (!baseline.has(row.id)) params.write({ type: `insert`, value: row }) + params.commit() + params.markReady() + } + writeMissing([{ id: 1, parentId: 1 }]) + const reload = source.collection.reload() + writeMissing([{ id: 1, parentId: 1 }]) + await reload + expect([...source.collection.keys()]).toEqual([1]) + await source.collection.cleanup() + }) + + // The oracle's driver writes one row key, so it cannot see rows that a + // reload failed to replace. This witness writes a new key in the new run. + it(`replaces the rows at the commit after a canceled first commit`, async () => { + const source = createSource(`A`) + source.collection.startSyncImmediate() + const first = source.params + first.begin() + first.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + first.commit() + first.markReady() + const reload = source.collection.reload() + const next = source.params + const canceled = new AbortController() + canceled.abort() + next.begin() + next.write({ type: `insert`, value: { id: 3, parentId: 3 } }) + try { + next.commit(canceled.signal) + } catch { + // A canceled commit may throw its abort reason. + } + // The run continues at once, before the canceled receipt settles. + next.begin() + next.write({ type: `insert`, value: { id: 2, parentId: 2 } }) + next.commit() + next.markReady() + await reload + expect(source.collection.errors.reloading).toBe(false) + expect([...source.collection.keys()]).toEqual([2]) + await source.collection.cleanup() + }) + it(`drops the retired run's open sync transaction`, async () => { const source = createSource(`A`) source.collection.startSyncImmediate() @@ -1331,27 +1413,21 @@ describe(`root request errors`, () => { }) // Two root loads of one source can overlap: a window move, then a refill -// after a visible row is deleted. A success clears only the request errors of -// loads started before it. The model: a failure stays shown until a load that -// started after it succeeds after it failed. It shows the latest failure. +// after a visible row is deleted. A request error clears only when a load +// started after the failure succeeds, or the demand is released: a load +// already in flight cannot cover the data whose request failed. Both loads +// here start before either settles, so once one fails the error stays, and +// it shows the latest failure. type OverlapHistory = { outcomes: readonly [`ok` | `fail`, `ok` | `fail`] - // Which load settles first: the window move (0) or the refill (1). + // Which load settles first: the earlier-started (0) or the later one (1). first: 0 | 1 } function expectedOverlap({ outcomes, first }: OverlapHistory) { const order = first === 0 ? ([0, 1] as const) : ([1, 0] as const) - const failed = new Set() let shown: string | null = null return order.map((load) => { - if (outcomes[load] === `fail`) { - failed.add(load) - shown = `load ${load} failed` - } else { - for (const earlier of [...failed]) - if (earlier < load) failed.delete(earlier) - if (failed.size === 0) shown = null - } + if (outcomes[load] === `fail`) shown = `load ${load} failed` return shown }) } @@ -1427,6 +1503,30 @@ describe(`overlapping root loads`, () => { ).toBe(model[step]) expect(query.status).toBe(`ready`) } + // A load started after the failure clears it when it succeeds. + if (model.at(-1) !== null) { + void Promise.resolve( + query.utils.setWindow({ offset: 6, limit: 2 }), + ).catch(() => undefined) + await flushPromises() + // The loader may have started more loads while the two settled; a + // load started before the failure clears nothing, so settle every + // load until the one the window move started has succeeded. + const started = loads.length + for (let index = 2; index < loads.length; index++) { + loads[index]!.resolve() + await flushPromises() + } + expect( + loads.length, + `the window move started a load`, + ).toBeGreaterThan(2) + expect(started).toBeGreaterThan(2) + expect( + query.errors.error?.message ?? null, + `after a later load succeeds`, + ).toBeNull() + } } finally { console.error = consoleError await query.cleanup() @@ -1436,6 +1536,103 @@ describe(`overlapping root loads`, () => { ) }) +// An Effect's lazy join loads for one source overlap when two parents arrive +// before the first child load settles. The same law and model apply. +describe(`overlapping Effect loads`, () => { + const histories: Array = [] + for (const a of [`ok`, `fail`] as const) + for (const b of [`ok`, `fail`] as const) + for (const first of [0, 1] as const) + histories.push({ outcomes: [a, b], first }) + + it.each(histories)( + `outcomes $outcomes, load $first settles first`, + async (history) => { + const loads: Array>> = [] + let deferLoads = false + const parents = createSource(`B`) + const children = createCollection({ + id: `health-effect-overlap-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => { + if (!deferLoads) return Promise.resolve() + const load = createDeferred() + loads.push(load) + return load.promise + }, + } + }, + }, + }) + const trees: Array = [] + parents.collection.startSyncImmediate() + const insertParent = (id: number) => { + parents.params.begin() + parents.params.write({ type: `insert`, value: { id, parentId: id } }) + parents.params.commit() + } + const consoleError = console.error + console.error = () => {} + const effect = createEffect({ + query: (q) => + q + .from({ parent: parents.collection }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + onBatch: () => {}, + onErrorsChange: (errors) => trees.push(errors), + }) + try { + insertParent(10) + parents.params.markReady() + await flushPromises() + deferLoads = true + insertParent(11) + await flushPromises() + insertParent(12) + await flushPromises() + // Premise: both child loads are in flight at once. + expect(loads).toHaveLength(2) + const model = expectedOverlap(history) + const order = history.first === 0 ? [0, 1] : [1, 0] + for (const [step, load] of order.entries()) { + if (history.outcomes[load] === `ok`) loads[load]!.resolve() + else loads[load]!.reject(new Error(`load ${load} failed`)) + await flushPromises() + expect( + trees.at(-1)?.error?.message ?? null, + `after load ${load} settles`, + ).toBe(model[step]) + } + // A load started after the failure clears it when it succeeds. + if (model.at(-1) !== null) { + insertParent(13) + await flushPromises() + expect(loads).toHaveLength(3) + loads[2]!.resolve() + await flushPromises() + expect( + trees.at(-1)?.error ?? null, + `after a later load succeeds`, + ).toBeNull() + } + } finally { + console.error = consoleError + await effect.dispose() + await parents.collection.cleanup() + await children.cleanup() + } + }, + ) +}) + // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index ae5ca58134..dc449153b6 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1757,12 +1757,19 @@ function createElectricSync>( begin, write, commit: commitSyncTransaction, - markReady, fail, truncate, collection, metadata, } = params + // Readiness of this sync run. A reload runs `sync` again while the + // Collection still shows the previous run's status, so status cannot say + // whether this run has reached its first ready. + let ready = false + const markReady = () => { + ready = true + params.markReady() + } // The stream can give up from several places; the first one stops this // sync run, and fail() may be called once. let stopped = false @@ -1950,7 +1957,7 @@ function createElectricSync>( giveUp(cause) } const settleFirstSnapshot = (isBuffering: boolean, failure?: unknown) => { - if (sessionCleanedUp || collection.status !== `loading`) return + if (sessionCleanedUp || ready || stopped) return if (failure !== undefined) { initialSyncFailedFinally = true giveUp(failure) @@ -1961,7 +1968,7 @@ function createElectricSync>( // An external abort stops the source for good. Before the first ready, // that is the final initial failure; cleanup aborts internally instead. const forwardExternalAbort = () => { - const fails = isActiveLifecycle() && collection.status === `loading` + const fails = isActiveLifecycle() && !ready abortController.abort() if (fails) failInitialSync(new StreamAbortedError(collectionId)) } @@ -2008,7 +2015,7 @@ function createElectricSync>( // Waiting loads and readiness waits reject with the same cause. const failWaits = (cause: unknown) => { rejectFullSnapshot(cause) - if (collection.status === `loading`) failInitialSync(cause) + if (!ready) failInitialSync(cause) } if (shapeOptions.onError) { @@ -2022,7 +2029,7 @@ function createElectricSync>( if (!retry || typeof retry !== `object`) failWaits(errorParams) return retry } else { - const initialSyncFailed = collection.status === `loading` + const initialSyncFailed = !ready failWaits(errorParams) console.error( `An error occurred while syncing collection: ${collection.id}, \n` + @@ -2633,8 +2640,7 @@ function createElectricSync>( const readyErrorVersion = streamErrorVersion // Readiness counts accepted rows. const accepted = whenSyncAccepted(applied) - const firstSnapshot = - commitPoint === `up-to-date` && collection.status === `loading` + const firstSnapshot = commitPoint === `up-to-date` && !ready if (commitPoint === `up-to-date`) resolveFullSnapshot(applied) if (accepted === true) { wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion) @@ -2719,8 +2725,8 @@ function createElectricSync>( // retry loop. No provider batch will settle the gate. rejectFullSnapshot(error) if (isActiveLifecycle() && !abortController.signal.aborted) { - if (collection.status === `loading`) failInitialSync(error) - else if (collection.status === `ready`) giveUp(error) + if (!ready) failInitialSync(error) + else giveUp(error) } }, ) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 3f0aa80c5f..ac4d3944fe 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -229,6 +229,44 @@ describe(`Electric Integration`, () => { expect(collection.status).toEqual(`ready`) }) + // A reload runs `sync` again while the Collection still shows `error`. + // The new stream's outcome, not that status, decides the reload. + it.each([`up-to-date`, `stream error`] as const)( + `settles a reload after a failed first load: new stream %s`, + async (outcome) => { + const loggedError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + const lastStreamOptions = () => + vi.mocked(ShapeStream).mock.calls.at(-1)?.[0] as + { onError?: (error: unknown) => void } | undefined + try { + const preload = collection.preload() + lastStreamOptions()?.onError?.(new Error(`initial stream failed`)) + await preload.catch(() => undefined) + expect(collection.status).toBe(`error`) + + const reload = collection.reload().then( + () => `resolved`, + () => `rejected`, + ) + if (outcome === `up-to-date`) + subscriber([{ headers: { control: `up-to-date` } }]) + else lastStreamOptions()?.onError?.(new Error(`reload stream failed`)) + const settled = await Promise.race([ + reload, + new Promise((resolve) => setTimeout(() => resolve(`pending`), 200)), + ]) + expect(settled).toBe(outcome === `up-to-date` ? `resolved` : `rejected`) + expect(collection.status).toBe( + outcome === `up-to-date` ? `ready` : `error`, + ) + } finally { + loggedError.mockRestore() + } + }, + ) + it(`reports an initial stream error instead of publishing an empty ready snapshot`, async () => { const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) const preload = collection.preload() diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index 3c59da77cf..eb3b37645b 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -325,16 +325,22 @@ function createPowerSyncCollectionConfig< */ const sync: SyncConfig = { sync: (params) => { - const { - begin, - write, - collection, - commit, - markReady, - fail, - reportError, - reportHealthy, - } = params + const { begin, write, collection, commit, reportError, reportHealthy } = + params + // Readiness of this sync run: a reload runs `sync` again while the + // Collection still shows the previous run's status. + let ready = false + let failed = false + const markReady = () => { + ready = true + params.markReady() + } + // A failure before this run's first ready fails its load, once. + const fail = (error: unknown) => { + if (failed) return + failed = true + params.fail(error) + } const abortController = new AbortController() let disposeTracking: @@ -634,9 +640,7 @@ function createPowerSyncCollectionConfig< message: `Could not start syncing process for ${viewName} into ${trackedTableName}`, error, }) - if (collection.status === `loading`) { - fail(error) - } + if (!ready) fail(error) }) const invokeOnUnload = (): void | Promise => { @@ -839,10 +843,10 @@ function createPowerSyncCollectionConfig< // Collection readiness this is a failureReason, not fail(). if (stopped) { // Cleanup owns the outcome. - } else if (collection.status === `ready`) { + } else if (ready) { trackingOutage = true reportError(error) - } else if (collection.status === `loading`) fail(error) + } else fail(error) throw error }) .finally(() => { diff --git a/packages/powersync-db-collection/tests/load-hooks.test.ts b/packages/powersync-db-collection/tests/load-hooks.test.ts index ccdd4ee52f..d4866a5e6e 100644 --- a/packages/powersync-db-collection/tests/load-hooks.test.ts +++ b/packages/powersync-db-collection/tests/load-hooks.test.ts @@ -341,6 +341,41 @@ describe(`Sync Streams`, () => { } }) + // A reload runs `sync` again while the Collection still shows `error`. + // The new run's readiness, not that status, decides its outcome. + it.each([`ok`, `fail`] as const)( + `eager mode: settles a reload after a failed first load: new run %s`, + async (outcome) => { + const db = await createDatabase() + const answers = [`fail`, outcome] + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + onLoad: () => + answers.shift() === `fail` + ? Promise.reject(new Error(`PowerSync load failed`)) + : Promise.resolve(), + }), + ) + try { + await collection.preload().catch(() => undefined) + expect(collection.status).toBe(`error`) + const settled = await Promise.race([ + collection.reload().then( + () => `resolved`, + () => `rejected`, + ), + new Promise((resolve) => setTimeout(() => resolve(`pending`), 1000)), + ]) + expect(settled).toBe(outcome === `ok` ? `resolved` : `rejected`) + expect(collection.status).toBe(outcome === `ok` ? `ready` : `error`) + } finally { + await collection.cleanup() + } + }, + ) + it(`eager mode: releases a load hook that resolves after cleanup`, async () => { const db = await createDatabase() const releaseLoad = pDefer() diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 91f169bb46..86a81ff0f8 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1123,6 +1123,27 @@ export function queryCollectionOptions( metadata, } = params + // Readiness of this sync run. A reload calls `sync` again while the + // Collection still shows the previous run's status, so status cannot say + // whether this run has reached its first ready. + let runReady = false + let runFailed = false + const readyRun = () => { + if (runReady || runFailed) return + runReady = true + markReady() + } + // A failure before this run's first ready fails its load; after it, a + // failureReason, since TanStack Query fetches again. + const failBeforeReady = (error: unknown): boolean => { + if (runReady) return false + if (!runFailed) { + runFailed = true + fail(error) + } + return true + } + /** * The LoadSubset contract: a sync adapter that refetches loaded subsets on * its own either keeps served prefixes whole, or reports that they may @@ -1258,12 +1279,12 @@ export function queryCollectionOptions( const failingQueries = new Set() const reportFailing = (hashedQueryKey: string, error: unknown) => { failingQueries.add(hashedQueryKey) - if (collection.status === `ready`) reportError(error) + if (runReady) reportError(error) } const reportRecovered = (hashedQueryKey: string) => { if (!failingQueries.delete(hashedQueryKey) || failingQueries.size > 0) return - if (collection.status === `ready`) reportHealthy() + if (runReady) reportHealthy() } const exceptionalResultSettlements = new WeakMap< QueryObserverResult, @@ -2775,8 +2796,8 @@ export function queryCollectionOptions( }) if (!signal?.aborted) { // A later successful refetch does not revive a failed initial - // load; only a restart does. - if (collection.status !== `error`) markReady() + // load; only a reload does. + readyRun() } } const accepted = whenSyncAccepted(applied) @@ -2875,8 +2896,7 @@ export function queryCollectionOptions( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) - if (collection.status === `loading`) fail(error) - else reportFailing(hashedQueryKey, error) + if (!failBeforeReady(error)) reportFailing(hashedQueryKey, error) }, ) } @@ -3246,8 +3266,8 @@ export function queryCollectionOptions( // snapshot available and report a failureReason. // A re-notified error is the same failure, so it is not reported // again. - if (collection.status === `loading`) fail(result.error) - else if (isNewError) reportFailing(hashedQueryKey, result.error) + if (!failBeforeReady(result.error) && isNewError) + reportFailing(hashedQueryKey, result.error) } } return handleQueryResult @@ -3354,15 +3374,11 @@ export function queryCollectionOptions( } } else { if (startupRetentionSettled) { - markReady() + readyRun() } else { // In on-demand mode, there is no initial query, but retained-placeholder // maintenance still needs to finish before the collection is treated as ready. - void startupRetentionMaintenancePromise.then(() => { - if (collection.status === `loading`) { - markReady() - } - }) + void startupRetentionMaintenancePromise.then(readyRun) } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 3f67c0414e..da7f20fa9d 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11717,3 +11717,66 @@ describe(`QueryCollection`, () => { }) }) }) + +/** + * A reload runs the Query Collection's `sync` again while the Collection still + * shows the previous run's status. The new run's first successful result makes + * it ready, and a failure before that fails the reload, whatever status the + * Collection showed when the reload began. The lane crosses the status before + * the reload (a failed first load, or ready) with the new run's outcome. + */ +describe(`reload settles on the new run's first result`, () => { + const histories = [`error`, `ready`].flatMap((before) => + [`ok`, `fail`].map((outcome) => ({ before, outcome })), + ) as Array<{ before: `error` | `ready`; outcome: `ok` | `fail` }> + + it.each(histories)( + `from $before, the new run's first fetch is $outcome`, + async ({ before, outcome }) => { + const answers: Array<`ok` | `fail`> = [ + before === `error` ? `fail` : `ok`, + outcome, + ] + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const collection = createCollection( + queryCollectionOptions({ + id: `reload-${before}-${outcome}`, + queryKey: [`reload`, before, outcome], + queryClient: client, + getKey: (row: { id: number }) => row.id, + queryFn: (): Promise> => + answers.shift() === `fail` + ? Promise.reject(new Error(`backend down`)) + : Promise.resolve([{ id: 1 }]), + }), + ) + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + try { + await collection.preload().catch(() => undefined) + expect(collection.status).toBe(before) + const settled = await Promise.race([ + collection.reload().then( + () => `resolved`, + () => `rejected`, + ), + new Promise((resolve) => setTimeout(() => resolve(`pending`), 200)), + ]) + expect(settled).toBe(outcome === `ok` ? `resolved` : `rejected`) + expect(collection.status).toBe(outcome === `ok` ? `ready` : before) + expect(collection.errors.reloading).toBe(false) + expect(collection.errors.error?.message ?? null).toBe( + outcome === `ok` ? null : `backend down`, + ) + if (outcome === `ok`) expect(collection.size).toBe(1) + } finally { + consoleError.mockRestore() + await collection.cleanup() + client.clear() + } + }, + ) +}) diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index ac071df346..e27b7e957c 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -273,15 +273,17 @@ export function rxdbCollectionOptions( } if (!isCleanedUp()) { + ready = true markReady() } } + // Readiness of this sync run: a reload runs `sync` again while the + // Collection still shows the previous run's status. + let ready = false void start().catch((error: unknown) => { stopOngoingFetch() - if (collection.status === `loading`) { - fail(error) - } + if (!ready) fail(error) }) return stopOngoingFetch diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index bf4fdf6720..308d9c8851 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -142,6 +142,48 @@ describe(`RxDB Integration`, () => { } }) + // A reload runs `sync` again while the Collection still shows `error`. + // The new run's readiness, not that status, decides its outcome. + it.each([`ok`, `fail`] as const)( + `settles a reload after a failed first load: new run %s`, + async (outcome) => { + const db = await getDatababase(getTestData(1)) + const rxCollection: RxCollection = db.test + const query = vi + .spyOn(rxCollection.storageInstance, `query`) + .mockRejectedValueOnce(new Error(`first query failed`)) + if (outcome === `fail`) + query.mockRejectedValueOnce(new Error(`reload query failed`)) + const collection = createCollection( + rxdbCollectionOptions({ + rxCollection, + startSync: true, + syncBatchSize: 10, + }), + ) + try { + await collection.preload().catch(() => undefined) + expect(collection.status).toBe(`error`) + const settled = await Promise.race([ + collection.reload().then( + () => `resolved`, + () => `rejected`, + ), + new Promise((resolve) => setTimeout(() => resolve(`pending`), 500)), + ]) + expect(settled).toBe(outcome === `ok` ? `resolved` : `rejected`) + expect(collection.status).toBe(outcome === `ok` ? `ready` : `error`) + if (outcome === `ok`) expect(collection.size).toBe(1) + else + expect(collection.errors.error?.message).toBe(`reload query failed`) + } finally { + query.mockRestore() + await collection.cleanup() + await db.remove() + } + }, + ) + it(`marks initial sync ready only after its rows are applied`, async () => { const db = await getDatababase([{ id: `server`, name: `Server` }]) const rxCollection: RxCollection = db.test diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index 5ed73b985a..ced37d62e9 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -185,7 +185,7 @@ export function trailBaseCollectionOptions< type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, fail, collection } = params + const { begin, write, commit, markReady, fail } = params let cancelled = false let periodicCleanupTask: ReturnType | undefined const cleanup = () => { @@ -398,14 +398,13 @@ export function trailBaseCollectionOptions< } // The adapter does not resubscribe, so it stops when its stream ends; - // the first end reports why, and fail() may be called once. + // the first end reports why, and fail() may be called once. These flags + // belong to this sync run: a reload runs `sync` again while the + // Collection still shows the previous run's status. let streamEnded = false function reportStreamEnd(error: unknown, closed = false) { if (cancelled) return - if ( - !streamEnded && - (collection.status === `loading` || collection.status === `ready`) - ) { + if (!streamEnded) { streamEnded = true fail(error) } else if (!closed) @@ -454,7 +453,7 @@ export function trailBaseCollectionOptions< if (cancelled) return fullSyncCompleted = true } - if (!cancelled && collection.status === `loading`) { + if (!cancelled && !streamEnded) { markReady() } } catch (error) { diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index c2def204f5..04e2c2f919 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -81,11 +81,13 @@ The same interpreter runs a fixed corpus and generated histories. - The adapter subscribes once per sync run and does not resubscribe. After the stream closes or fails, the Collection keeps its rows and stays ready, a later service change does not reach it, and a later on-demand load lists - the service's current rows without waiting. Collection health holds a final - health error from the end onward, with a stream failure as its cause, and - only a restart clears it. Reconnecting is left to the TrailBase provider. - A mutant that reports the end as a health error that is not final fails 29 - lifecycle cells. Two histories make this limit + the service's current rows without waiting. The Collection's node shows an + `error` from the end onward, with a stream failure as its cause, and only + `reload()` or a restart clears it. Reconnecting is left to the TrailBase + provider. A mutant that reports the end as a `failureReason` fails 29 + lifecycle cells. A separate lane checks that a reload after a failed startup + settles on the new subscription's outcome while the Collection still shows + `error`. Two histories make this limit executable; they do not claim it is the right product behavior. They pin one stream end followed by one change and one load. A page whose newer event never arrives because the stream ended keeps the value the fence diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index c41cd8140b..20ec5b01db 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -1169,7 +1169,7 @@ it.each(heldPlans)( * reach it, and a later on-demand load does not wait for a new subscription. * The load lists the service's current rows. Because the sync adapter stops * receiving changes, it reports the Collection's `error`, so an application can - * see that the rows can grow stale; only a restart subscribes again. This + * see that the rows can grow stale; only a reload or restart subscribes again. This * lane makes that limit executable; reconnecting is left to the TrailBase * provider, and the lane does not claim the limit is the right product * behavior. @@ -1260,6 +1260,64 @@ it.each([`close`, `read-error`] as const)( }, ) +/** + * ## Does a reload recover a failed startup? + * + * Law (the error surface's reload contract): `reload()` runs `sync` again + * while the Collection still shows `error`. The new run's own outcome decides + * the reload: a subscription that listens makes it ready and the reload + * resolves; a subscription that rejects fails the reload, and the status stays + * `error`. The adapter cannot read Collection status to learn whether this run + * is ready, because status still shows the failed run. + * + * Grammar (exhaustive, 2 histories): the first subscription rejects; the + * reload's subscription listens or rejects. Checkpoints: after the reload + * settles (or a bounded wait), compare its outcome, status, and subscribe + * calls. + */ +it.each([`listens`, `rejects`] as const)( + `a reload after a failed startup settles when the new subscription %s`, + async (outcome) => { + const stream = new ReadableStream({ start() {} }) + const api = new MockRecordApi() + api.subscribe + .mockImplementationOnce(() => Promise.reject(failure)) + .mockImplementationOnce(() => + outcome === `listens` + ? Promise.resolve(stream) + : Promise.reject(new Error(`reload subscription failed`)), + ) + api.list.mockImplementation(() => Promise.resolve({ records: [] })) + const reported = vi.spyOn(console, `error`).mockImplementation(() => {}) + const collection = createCollection( + trailBaseCollectionOptions({ + recordApi: api, + getKey: (row: RaceRow) => row.id, + startSync: true, + parse: {}, + serialize: {}, + }), + ) + try { + await collection.preload().catch(() => undefined) + expect(collection.status).toBe(`error`) + const settled = await Promise.race([ + collection.reload().then( + () => `resolved`, + () => `rejected`, + ), + new Promise((resolve) => setTimeout(() => resolve(`pending`), 500)), + ]) + expect(settled).toBe(outcome === `listens` ? `resolved` : `rejected`) + expect(collection.status).toBe(outcome === `listens` ? `ready` : `error`) + expect(api.subscribe).toHaveBeenCalledTimes(2) + } finally { + await collection.cleanup() + reported.mockRestore() + } + }, +) + /** * ## Do several pages, several changes, and overlapping loads still converge? * From 649b56f8fe76fdfc52166d87080504273f9dd2cb Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 10:25:36 -0600 Subject: [PATCH 069/106] fix: close the high-review findings and the joined-filter window stall Each fix has a witness that failed before the change: - A failed join load after readiness settles its demand, so an ordered window that filters on the joined source moves again; it froze before. - A query's reload() waits for the query's own restarted run, and a crashed query rejects without touching its sources. - A throwing sync function during a reload of a failed first load crashes the Collection: the run, not the previous status, decides. - Cleaning up one pooled view keeps its sibling views' rows; the partition counts its views and releases after the last one. - Include nodes keep `instances` after cleanup; the query decides them. - `instances` drops `total`: every tree change is now announced, so the observer reuses the tree from its last event instead of rebuilding it on every snapshot. - The persisted wrapper crashes the Collection on an adapter call that breaks the state machine instead of swallowing it. - query-db's failureReason shows a Query that is still failing. - Misplaced JSDoc and stale comments; a redundant release scheduling. The source-health model now reloads a query itself once the Collections its reload() waits on settle; a 20x run found that gap. 100x runs pass. Co-authored-by: Isaac --- docs/guides/error-handling.md | 2 +- .../src/persisted.ts | 19 +- .../tests/persisted-readiness-oracle.test.ts | 30 ++ packages/db/mangle-cache.json | 5 +- packages/db/src/collection/error-tree.ts | 5 +- packages/db/src/collection/lifecycle.ts | 5 + packages/db/src/collection/subscription.ts | 7 +- packages/db/src/collection/sync.ts | 8 +- packages/db/src/index.ts | 1 - packages/db/src/live-query-observer.ts | 16 +- packages/db/src/query/live/ARCHITECTURE.md | 10 +- .../query/live/collection-config-builder.ts | 22 +- packages/db/src/query/pooled-live-query.ts | 21 +- packages/db/src/types.ts | 10 +- .../query/pooled-live-query-gc-oracle.test.ts | 40 ++ .../db/tests/source-health-oracle.test.ts | 351 ++++++++++++++++-- packages/query-db-collection/src/query.ts | 14 +- .../query-db-collection/tests/query.test.ts | 74 ++++ packages/react-db/src/useLiveQuery.ts | 29 +- packages/solid-db/src/useLiveQuery.ts | 29 +- 20 files changed, 592 insertions(+), 106 deletions(-) diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 76ecb5207e..43ed7e6b90 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -318,7 +318,7 @@ load failure in its `error`. A Collection used twice in one query, for example in a self-join, has one node per alias. An include has one node for all of its per-row child Collections, -with `instances: { total, withErrors }`. A nested live query's node carries its +with `instances: { withErrors }`, the number of them that have a problem. A nested live query's node carries its own sources. ### Collections without a live query diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index cfc3dfa4e4..a623a3a5fc 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -5913,10 +5913,21 @@ function createWrappedSyncConfig< if (startupState.cleanedUp || getTerminalFailure()) return adapterCalls = adapterCalls .then(() => fullStartPromise ?? runtime.ensureStarted()) - .then(() => { - if (!isCleanedUp() && !getTerminalFailure()) return call() - }) - .catch(() => undefined) + .then( + async () => { + if (isCleanedUp() || getTerminalFailure()) return + try { + await call() + } catch (error) { + // The call broke the Collection's state machine. It is too + // late to throw at the adapter, so it crashes the Collection, + // as a throwing sync function does. + params.collection._lifecycle.crash(error) + } + }, + // A failed startup reports its own failure. + () => undefined, + ) } const wrappedParams = { ...params, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts index a80faf419c..28ea85db45 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts @@ -312,6 +312,36 @@ describe(`persisted readiness oracle`, () => { }, ) + // The wrapper delivers the adapter's calls after startup, so a contract + // violation cannot throw back at the adapter. It crashes the Collection + // with the violation as its cause, as a throwing sync function does. + it(`crashes on an adapter call that breaks the state machine`, async () => { + const fixture = controlledAdapter() + const collection = createCollection( + persistedCollectionOptions({ + id: `contract-violation`, + getKey: (row) => row.id, + persistence: { adapter: fixture.adapter }, + sync: { + sync: (params) => { + params.reportError(new Error(`reported before ready`)) + }, + }, + }), + ) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + void collection.preload().catch(() => undefined) + fixture.load.resolve([]) + await vi.advanceTimersByTimeAsync(0) + expect(collection.status).toBe(`crashed`) + expect(collection.errors.error?.message).toMatch(/reportError/) + } finally { + consoleError.mockRestore() + await collection.cleanup({ dependents: true }) + } + }) + it(`keeps a Collection without initialRender on the network boundary`, async () => { const source = networkFirst(`no-initial-render`, false) try { diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 7f80c47257..12c430b350 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -436,5 +436,8 @@ "loadOrder": "ha", "reloadTruncateTransaction": "gY", "cancelSyncTransaction": "g7", - "startedBeforeFailure": "hb" + "startedBeforeFailure": "hb", + "publishedErrors": "hc", + "instancedSourceIds": "hd", + "views": "he" } diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index e89916658a..436d153d66 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -99,12 +99,12 @@ export function findProblems(tree: ErrorTreeNode): Array { function countInstances( collections: ReadonlyArray>, -): { total: number; withErrors: number } { +): { withErrors: number } { let withErrors = 0 for (const instance of collections) { if (hasOwnProblem(instance)) withErrors++ } - return { total: collections.length, withErrors } + return { withErrors } } /** Return the cached node for this Collection and alias if nothing changed. */ @@ -130,7 +130,6 @@ function sameNode(a: ErrorTreeNode, b: ErrorTreeNode): boolean { a.failureReason === b.failureReason && a.error === b.error && a.reloading === b.reloading && - a.instances?.total === b.instances?.total && a.instances?.withErrors === b.instances?.withErrors && a.sources.length === b.sources.length && a.sources.every((source, index) => source === b.sources[index]) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index c6e78b7bb5..bf0878a285 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -315,6 +315,11 @@ export class CollectionLifecycleManager< reload?.reject(error) } + /** Whether the current sync run's adapter called `fail()`. */ + public get stoppedThisRun(): boolean { + return this.adapterStopped + } + /** Whether a reload's new sync run is in flight. */ public get reloading(): boolean { return this.reloadRun !== undefined diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 5af198d2c1..726fed41d2 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -311,16 +311,15 @@ export class CollectionSubscription } } - /** Detach logical demand from work owned by a discarded sync run. */ - /** @internal A reload retired the sync run; detach its demand. */ /** - * A reload keeps each retained demand's waiting caller: the new sync run - * re-acquires the demand and settles that caller. + * @internal A reload retired the sync run. It keeps each retained demand's + * waiting caller: the new sync run re-acquires the demand and settles it. */ public retireSyncRun(): void { this.handleCollectionCleanup(true) } + /** Detach logical demand from work owned by a discarded sync run. */ private handleCollectionCleanup(keepWaitingCallers = false): void { this.discardTruncateReplay() this.stalePublishedRows = new Map(this.publishedRows) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 4c4e2d6f2c..02dbe8de9e 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -469,10 +469,10 @@ export class CollectionSyncManager< this.completePendingSyncEntryCleanup() finishReadyFailures() // A throwing sync function is a programming error, even after ready. - // A first load that already failed in this run reported its error. - // The sync function can change the status before it throws. - const status = this.lifecycle.status - if (isCurrentSync() && status !== `error`) this.lifecycle.crash(error) + // A run that already called fail() reported its error. Status cannot + // tell: during a reload it still shows the previous run's. + if (isCurrentSync() && !this.lifecycle.stoppedThisRun) + this.lifecycle.crash(error) throw error } if (readyFailure) throw readyFailure.error diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8d2f80dd40..8b0eb0b3c9 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -16,7 +16,6 @@ export * from './query/index.js' export * from './optimistic-action' export * from './live-query-adapter' export * from './live-query-observer' -/** @internal First-party persistence adapter capability. */ export * from './live-query-options' /** @internal Unstable adapter primitive for RFC #1623. */ export * from './live-query-window-controller' diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index d7ac6af280..9c628d13dc 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -163,6 +163,9 @@ class LiveQueryObserverImpl< private cachedCollectionRevision: number | undefined private cachedCollectionLayoutRevision: number | undefined private snapshotDirty = true + // While attached, the tree from the last status or errors change. Every + // tree change is announced, so a snapshot reuses it instead of rebuilding. + private publishedErrors: { tree: ErrorTreeNode | undefined } | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private layoutRevision = 0 private lastLayoutKeys: Array | undefined @@ -251,7 +254,9 @@ class LiveQueryObserverImpl< this.layoutRevision++ } - const errors = (collection as { errors?: ErrorTreeNode }).errors + const errors = this.publishedErrors + ? this.publishedErrors.tree + : (collection as { errors?: ErrorTreeNode }).errors // Most consumers read only `data`; build the keyed view on first read. let state: Map | undefined this.cachedSnapshot = { @@ -673,15 +678,15 @@ class LiveQueryObserverImpl< // Each status change is followed by an errors change; tree identity // tells whether the errors change has anything left to publish. const errorsOf = () => (collection as { errors?: ErrorTreeNode }).errors - let publishedErrors = errorsOf() + this.publishedErrors = { tree: errorsOf() } const statusUnsub = collection.on(`status:change`, ({ status }) => { - publishedErrors = errorsOf() + this.publishedErrors = { tree: errorsOf() } notify(undefined, status) }) const errorsUnsub = collection.on(`errors:change`, () => { const errors = errorsOf() - if (errors === publishedErrors) return - publishedErrors = errors + if (errors === this.publishedErrors?.tree) return + this.publishedErrors = { tree: errors } // No rows or status changed, so only this marks the snapshot stale. this.snapshotDirty = true notify(undefined, collection.status) @@ -725,6 +730,7 @@ class LiveQueryObserverImpl< }) : noop const release = () => { + this.publishedErrors = undefined clientUnsub() statusUnsub() errorsUnsub() diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 7fcf82d292..24ef9fba31 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1711,10 +1711,12 @@ every row before the top-K (a recorded limit). failed, the sync adapter stopped, the Collection crashed, or, on a query's own node, a request for more data failed). An include's source has `kind: 'include'`; when the include materializes a child Collection - per parent row, its node rolls those up as `instances: { total, -withErrors }`, counting only that include's children even when another - include reads the same alias, and a child's change is relayed like a - source's. A pooled live query's tree is the tree a live-query Collection + per parent row, its node rolls those up as `instances: { withErrors }`, + counting only that include's children with a problem even when another + include reads the same alias, and a child's change of problem is relayed + like a source's. The roll-up has no child count: every change to the tree + is announced by `errors:change`, so readers can keep a tree until the next + event, and adding a healthy child changes nothing. A pooled live query's tree is the tree a live-query Collection over its source would have, so it also reaches deeper levels. The shape is fixed by the query. Each node mirrors its Collection when read, and the query emits `errors:change` whenever a node below it changes. When a diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 2553645890..9b870548fd 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -150,7 +150,10 @@ export class CollectionConfigBuilder< findProblems(collection.errors).some((node) => node.error !== null), ) await Promise.all(failed.map((collection) => collection.reload())) - if (query?.status === `error`) await query._sync.reload() + // A recovered source may have restarted this query already; the reload + // settles with that run. + if (query?.status === `error` || query?._lifecycle.reloading) + await query._sync.reload() } /** This live query as its sources' `subscribers` list it. */ @@ -196,6 +199,9 @@ export class CollectionConfigBuilder< private pipelineCache: ResultStream | undefined public sourceWhereClausesCache: Map> | undefined + // Include sources with per-row child Collections. The query decides this, + // so it outlives the compiled cache that cleanup resets. + private readonly instancedSourceIds = new Set() private bucketFacadesCache: ReturnType[`facades`] | undefined private bucketFacades: BucketFacadeAdapter | undefined @@ -492,9 +498,7 @@ export class CollectionConfigBuilder< return this.collectionSources.map(({ sourceId, alias, collection }) => { const source = { id: sourceId, alias, collection } if (!this.includeSourceIds.has(sourceId)) return source - const instanced = this.bucketFacadesCache?.some((compilation) => - compilation.sourceIds.includes(sourceId), - ) + const instanced = this.instancedSourceIds.has(sourceId) return { ...source, kind: `include` as const, @@ -535,7 +539,12 @@ export class CollectionConfigBuilder< if (!demand || demand.generation !== generation) return const normalized = this.recordSubsetError(error) demand.participant?.reject(normalized) - if (this.reportFailedRequest(planId, normalized)) return + if (this.reportFailedRequest(planId, normalized)) { + // The failure settles the demand: the request error shows it, and + // ordered work must not wait for a load that already ended. + demand.settled = true + return + } this.failQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Subset demand '${planId}' failed: ${normalized.message}` @@ -991,6 +1000,9 @@ export class CollectionConfigBuilder< this.pipelineCache = materialized.pipeline this.sourceWhereClausesCache = compilation.sourceWhereClauses this.bucketFacadesCache = materialized.facades + for (const compilation of materialized.facades ?? []) + for (const sourceId of compilation.sourceIds) + this.instancedSourceIds.add(sourceId) const missingSources = this.collectionSources .map((source) => source.sourceId) diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 0976283442..6e683bb34c 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -80,6 +80,8 @@ class Partition { // One source status listener serves every view of this partition. readonly statusListeners = new Set() private listenerCount = 0 + // Views built on this partition and not yet cleaned up. + private views = 0 private gcTime = 0 /** * Set when the partition ends for good: `crashed` when its source starts @@ -162,6 +164,7 @@ class Partition { * one of its views' own live-query Collection would have. */ retain(gcTime?: number): void { + this.views++ if (gcTime !== undefined) { // As for a Collection, a non-positive or non-finite gcTime disables GC. const delay = gcTime > 0 && Number.isFinite(gcTime) ? gcTime : Infinity @@ -259,14 +262,15 @@ class Partition { } /** - * Release the source subscription now if no view listens, as cleaning up a - * live-query Collection releases its source subscriptions at once. A later - * mount subscribes again. + * One view was cleaned up. The last one releases the source subscription + * now if no view listens, as cleaning up a live-query Collection releases + * its source subscriptions at once; a sibling view keeps its rows and its + * own grace period. A later mount subscribes again. */ - releaseIfIdle(): void { + releaseView(): void { + if (--this.views > 0) return CleanupQueue.getInstance().cancel(this) this.releaseIfUnused() - this.scheduleRelease(0) } private readonly releaseIfUnused = (): void => { @@ -527,6 +531,8 @@ class PooledLiveQuery { readonly _subscribeLayoutChanges = undefined private collection: Collection | undefined = undefined private listenerCount = 0 + // This view's share of its partition ends once, at its first cleanup. + private released = false private collectionHold: { unsubscribe: () => void } | undefined = undefined constructor( @@ -676,7 +682,10 @@ class PooledLiveQuery { cleanup(options?: { dependents?: boolean }): Promise { const cleanup = this.collection?.cleanup(options) ?? Promise.resolve() - this.partition.releaseIfIdle() + if (!this.released) { + this.released = true + this.partition.releaseView() + } return cleanup } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 9ab4ca2ffe..16fd182d2f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -819,8 +819,8 @@ export interface ErrorTreeNode { * are the previous run's until it commits or fails. */ readonly reloading: boolean - /** For an include node: its per-row child Collections, and how many have a problem. */ - readonly instances?: { readonly total: number; readonly withErrors: number } + /** For an include node: how many of its per-row child Collections have a problem. */ + readonly instances?: { readonly withErrors: number } readonly sources: ReadonlyArray } @@ -1175,9 +1175,6 @@ export type KeyedNamespacedRow = [unknown, NamespacedRow] */ export type NamespacedAndKeyedStream = IStreamBuilder -/** - * Options for subscribing to collection changes - */ /** * Something that reads a Collection, as listed by `collection.subscribers`: * a live query, an Effect, or a direct subscription. @@ -1199,6 +1196,9 @@ export interface CollectionDependent { end: () => Promise } +/** + * Options for subscribing to collection changes + */ export interface SubscribeChangesOptions< T extends object = Record, TKey extends string | number = string | number, diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index d7a288b6cf..02f908a5b6 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -181,6 +181,46 @@ describe(`pooled live query gcTime`, () => { third.stop() }) + // Cleaning up one view ends that view only. A sibling built during a + // render, still inside its grace period to subscribe, keeps its rows and + // its partition, as its own live-query Collection would. + it(`keeps a built sibling's rows when another view is cleaned up`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `pooled-gc-sibling-${serial++}`, + getKey: (row) => row.id, + initialData: [ + { id: `a`, g: `x` }, + { id: `b`, g: `y` }, + ], + }), + ) + const view = (g: string) => + createPooledLiveQuery( + new Query() + .from({ r: source }) + .where(({ r }: any) => eq(r.g, g)) as any, + { gcTime: 100 }, + )! + const first = view(`x`) + const sibling = view(`y`) + const stop = createLiveQueryObserver(first, { + mode: `wholesale`, + }).subscribe(() => {}) + // Read the sibling without subscribing: it stays inside its grace period. + const siblingRows = () => [ + ...createLiveQueryObserver(sibling, { mode: `wholesale` }) + .getSnapshot() + .state!.keys(), + ] + await vi.advanceTimersByTimeAsync(0) + expect(siblingRows()).toEqual([`b`]) + stop() + await first.cleanup() + expect(siblingRows()).toEqual([`b`]) + expect(source.subscriberCount).toBe(1) + }) + it(`keeps a newer partition when an older one releases again`, async () => { const source = makeSource() const mount = () => { diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index d3b7eefc54..50b8b5f26e 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -31,7 +31,7 @@ * change replaces only the nodes on its path. * * An include's source node has `kind: 'include'` and rolls up the include's - * per-row child Collections as `instances: { total, withErrors }`. A pooled + * per-row child Collections as `instances: { withErrors }`. A pooled * live query shows its source's status and the same tree a live-query * Collection over that source would have, so its tree also reaches through * a nested live query. @@ -51,10 +51,9 @@ * The model keeps, for each Collection, its status and its problem after * readiness (none, a failure reason, or an error), with a serial for each * reported problem. It derives - * each live query's gate from its Collections, using only the rules above, and - * the include's parent rows from whether `B` synced its row while the - * sync run was running. It builds the expected tree from those facts. It does - * not read production state. + * each live query's gate from its Collections, using only the rules above, + * and builds the expected tree from those facts. It does not read production + * state. * * ## The history grammar * @@ -119,10 +118,11 @@ import { createLiveQueryCollection } from '../src/query/live-query-collection.js import { createEffect } from '../src/query/effect.js' import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' import { Query } from '../src/query/builder/index.js' -import { eq } from '../src/query/builder/functions.js' +import { eq, isUndefined, not } from '../src/query/builder/functions.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import { RequestErrors } from '../src/collection/lifecycle.js' import { BasicIndex } from '../src/indexes/basic-index.js' +import { BTreeIndex } from '../src/indexes/btree-index.js' import { createDeferred } from '../src/deferred.js' import { oraclePropertyOptions } from './oracle-config.js' import { flushPromises } from './utils.js' @@ -177,13 +177,19 @@ type SourceModel = GateModel & { stopped: Problem failureFromReload: boolean } +// A query's reload() reloads the Collections below it that show an error, +// then reloads the query itself once they all settle, unless one failed. +type PendingSelfReload = { + target: `query` | `inner` + waits: Set + failed: boolean +} type Model = { sources: Record query: GateModel inner: GateModel serial: number - // Parent rows the include query's sync run has received from `B`. - parentRows: number + selfReloads: Array } const shapeSources: Record> = { @@ -209,7 +215,7 @@ function freshModel(): Model { query: { gate: `loading`, cause: null, reloading: false }, inner: { gate: `loading`, cause: null, reloading: false }, serial: 0, - parentRows: 0, + selfReloads: [], } } @@ -252,15 +258,6 @@ function deriveQueries(shape: Shape, model: Model): void { model.query = model.inner } else { model.query = deriveGate(model.query, gates(shapeSources[shape])) - // A running sync run receives `B`'s row once `B` syncs it, including a - // run that started again; a failed sync run receives nothing more. - if ( - shape === `include` && - (model.query.gate === `loading` || model.query.gate === `ready`) && - model.sources.B.gate === `ready` - ) { - model.parentRows = 1 - } } } @@ -283,12 +280,38 @@ function applyModel( if (model.query.gate === `crashed`) return `throws` // The query reloads every Collection below it that shows an error, // through any nested live query; a reload in flight keeps its run. - for (const name of shapeSources[shape]) { + const reloaded = shapeSources[shape].filter((name) => { const source = model.sources[name] - if (source.gate === `error` || source.stopped !== null) - source.reloading = true + return source.gate === `error` || source.stopped !== null + }) + for (const name of reloaded) model.sources[name].reloading = true + if (shape === `nested`) { + // The outer query reloads the inner query when A shows an error; the + // inner query reloads A, then itself. + const viaInner = reloaded.includes(`A`) || model.inner.gate === `error` + if (viaInner) + model.selfReloads.push({ + target: `inner`, + waits: new Set(reloaded.includes(`A`) ? [`A`] : []), + failed: false, + }) + model.selfReloads.push({ + target: `query`, + waits: new Set([ + ...reloaded.filter((name) => name !== `A`), + ...(viaInner ? [`inner` as const] : []), + ]), + failed: false, + }) + } else { + model.selfReloads.push({ + target: `query`, + waits: new Set(reloaded), + failed: false, + }) } deriveQueries(shape, model) + settleSelfReloads(shape, model) return `ok` } // A step on a source this shape does not read has no effect. @@ -385,9 +408,56 @@ function applyModel( break } deriveQueries(shape, model) + settleSelfReloads(shape, model) return outcome } +// A reloaded Collection settles when its new run commits (ready) or fails. +// Once a query's reload has nothing left to wait for, it reloads the query +// itself if it still shows its failed first load. +function settleSelfReloads(shape: Shape, model: Model): void { + let progressed = true + while (progressed) { + progressed = false + for (const pending of [...model.selfReloads]) { + for (const wait of [...pending.waits]) { + const settled = + wait === `inner` + ? !model.selfReloads.some((other) => other.target === `inner`) + : !model.sources[wait].reloading + if (!settled) continue + pending.waits.delete(wait) + const gate = + wait === `inner` ? model.inner.gate : model.sources[wait].gate + const stopped = wait !== `inner` && model.sources[wait].stopped + if (gate !== `ready` || stopped) pending.failed = true + } + if (pending.waits.size > 0) continue + model.selfReloads.splice(model.selfReloads.indexOf(pending), 1) + progressed = true + const current = model[pending.target] + if (pending.failed || current.gate !== `error`) continue + // The restarted run fails at once on an input that still failed, and + // is ready at once when every input is ready. + const inputs = + pending.target === `inner` + ? [model.sources.A] + : shape === `nested` + ? [model.inner, model.sources.B] + : shapeSources[shape].map((name) => model.sources[name]) + const failedInput = inputs.find( + ({ gate }) => gate === `error` || gate === `crashed`, + ) + model[pending.target] = failedInput + ? { gate: `error`, cause: failedInput.cause, reloading: false } + : inputs.every(({ gate }) => gate === `ready`) + ? { gate: `ready`, cause: null, reloading: false } + : { gate: `error`, cause: current.cause, reloading: true } + deriveQueries(shape, model) + } + } +} + // A problem's message says which cause the node shows; `serial` (identity // only) says which report. type ExpectedProblem = null | { message: string; serial?: number } @@ -399,7 +469,7 @@ type ExpectedNode = { failureReason: ExpectedProblem error: ExpectedProblem reloading: boolean - instances?: { total: number; withErrors: number } + instances?: { withErrors: number } sources: Array } @@ -506,7 +576,7 @@ function expectedTree( source(`b`, B), { ...sourceNode(`a`, A, identity, `include`), - instances: { total: model.parentRows, withErrors: 0 }, + instances: { withErrors: 0 }, }, ]) case `pooled-nested`: @@ -967,6 +1037,12 @@ const namedHistories: Record> = { on(`cancel`, `A`), on(`ready`, `A`), ], + 'a reloaded source recovers while a nested source fails its first load': [ + on(`fail`, `B`), + { kind: `reload-query` }, + on(`fail`, `A`), + on(`ready`, `B`), + ], 'an initial error is final for its sync run': [ on(`fail`, `A`), on(`ready`, `A`), @@ -1087,6 +1163,109 @@ describe(`source failure and health oracle`, () => { // invariant directly: after the reload no transaction stays open, and the // new run's own commit decides the row. describe(`reload invariant`, () => { + // A query's reload() settles with the query: when a failed source + // recovers, the query restarts, and the reload waits for that run to be + // ready (or to fail) instead of resolving while the query is `loading`. + it(`settles a query reload with the query's restarted run`, async () => { + let runs = 0 + const loads: Array>> = [] + const parents = createCollection({ + id: `health-query-reload-parents-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + if (runs++ === 0) { + params.fail(new Error(`first load failed`)) + return + } + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + params.markReady() + }, + }, + }) + const children = createCollection({ + id: `health-query-reload-children-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => { + const load = createDeferred() + loads.push(load) + return load.promise + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q + .from({ parent: parents }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + ) + const consoleError = console.error + console.error = () => {} + try { + await query.preload().catch(() => undefined) + expect(query.status).toBe(`error`) + let settled = false + const reload = query.reload().finally(() => { + settled = true + }) + await flushPromises() + // The restarted query waits for its join load, so the reload waits too. + expect(query.status).toBe(`loading`) + expect(settled, `reload settled while the query was loading`).toBe(false) + for (const load of loads) load.resolve() + await reload + expect(query.status).toBe(`ready`) + } finally { + console.error = consoleError + for (const load of loads) load.resolve() + await query.cleanup() + await parents.cleanup() + await children.cleanup() + } + }) + + // A throwing sync function is a programming error in any run. During a + // reload of a failed first load the status still shows `error`, but this + // run did not fail, so the throw crashes the Collection. + it(`crashes when a reload's sync function throws after a failed first load`, async () => { + let runs = 0 + const collection = createCollection({ + id: `health-reload-throw-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + if (runs++ === 0) params.fail(new Error(`first load failed`)) + else throw new Error(`sync threw`) + }, + }, + }) + const consoleError = console.error + console.error = () => {} + try { + await collection.preload().catch(() => undefined) + expect(collection.status).toBe(`error`) + await expect(collection.reload()).rejects.toThrow(`sync threw`) + expect(collection.status).toBe(`crashed`) + expect(collection.errors.reloading).toBe(false) + } finally { + console.error = consoleError + await collection.cleanup() + } + }) + // A reload's next commit replaces every synced row, so the baseline an // adapter diffs against is empty until that commit. An adapter that writes // only rows missing from the baseline, as Query Collections do, must still @@ -1359,6 +1538,107 @@ describe(`a query's own failed first load`, () => { }) }) +// An ordered window that filters on a lazily joined source waits for its join +// loads before it moves. A join load that fails after readiness is a request +// error on the query's node, and it settles that demand: the window must not +// wait for it for good. A later move loads, moves the window, and clears the +// error. Every marker row exists from the start, so only the load fails. +describe(`a failed join load under an ordered filtered window`, () => { + it.each([`left`, `inner`] as const)( + `lets a later window move (%s join)`, + async (join) => { + let failLoads = false + const roots = createCollection<{ id: number; rank: number }, number>({ + id: `health-window-roots-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + sync: { + sync: (params) => { + params.begin() + for (let id = 1; id <= 10; id++) + params.write({ type: `insert`, value: { id, rank: id } }) + params.commit() + params.markReady() + }, + }, + }) + const markers = createCollection<{ id: number; rootId: number }, number>({ + id: `health-window-markers-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + sync: { + sync: (params) => { + params.begin() + for (let id = 1; id <= 10; id++) + params.write({ + type: `insert`, + value: { id: 100 + id, rootId: id }, + }) + params.commit() + params.markReady() + return { + loadSubset: () => + failLoads + ? Promise.reject(new Error(`marker load failed`)) + : Promise.resolve(), + } + }, + }, + }) + const query = createLiveQueryCollection((q) => { + const from = q.from({ root: roots }) + const joined = + join === `left` + ? from.leftJoin({ marker: markers }, ({ root, marker }) => + eq(root.id, marker.rootId), + ) + : from.join({ marker: markers }, ({ root, marker }) => + eq(root.id, marker.rootId), + ) + return joined + .where(({ marker }) => not(isUndefined(marker.id))) + .orderBy(({ root }) => root.rank) + .limit(2) + }) + const subscription = query.subscribeChanges(() => {}) + const consoleError = console.error + console.error = () => {} + const ids = () => query.toArray.map((row: any) => row.root.id as number) + try { + await query.preload() + expect(ids()).toEqual([1, 2]) + + failLoads = true + await expect( + Promise.resolve(query.utils.setWindow({ offset: 4, limit: 2 })), + ).rejects.toThrow(`marker load failed`) + expect(query.status).toBe(`ready`) + expect(query.errors.error).toMatchObject({ + message: `marker load failed`, + }) + + failLoads = false + await query.utils.setWindow({ offset: 6, limit: 2 }) + await flushPromises() + expect(ids(), `the window moves after the failure`).toEqual([7, 8]) + expect(query.errors.error, `the later load clears it`).toBeNull() + expect(query.status).toBe(`ready`) + } finally { + console.error = consoleError + subscription.unsubscribe() + await query.cleanup() + await roots.cleanup() + await markers.cleanup() + } + }, + ) +}) + describe(`root request errors`, () => { it(`a failed root load after initial-query readiness is a request error that clears when a later load settles`, async () => { const outcomes: Array<`ok` | `fail`> = [] @@ -1970,20 +2250,23 @@ describe(`include instances`, () => { await preload const includes = () => query.errors.sources.slice(1) expect(includes().map((node) => [node.kind, node.instances])).toEqual([ - [`include`, { total: 2, withErrors: 0 }], - [`include`, { total: 2, withErrors: 0 }], + [`include`, { withErrors: 0 }], + [`include`, { withErrors: 0 }], ]) let events = 0 const stop = query.on(`errors:change`, () => { events++ }) - // New parent rows start healthy child Collections. + // New parent rows start healthy child Collections. Every tree change is + // announced, so with no event the tree is the same object. + const treeBefore = query.errors B.write({ id: 3, parentId: 3 }) B.write({ id: 4, parentId: 4 }) await flushPromises() expect(events, `healthy children emit no errors:change`).toBe(0) - expect(includes()[0]!.instances).toEqual({ total: 4, withErrors: 0 }) + expect(query.errors, `no tree change without an event`).toBe(treeBefore) + expect(includes()[0]!.instances).toEqual({ withErrors: 0 }) // One child Collection of `items` fails: its index throws on row 9. class FailingIndex extends BasicIndex { override add(key: number, item: any): void { @@ -2001,9 +2284,9 @@ describe(`include instances`, () => { expect(query.status).toBe(`ready`) expect(events, `errors:change relay`).toBeGreaterThan(0) const [itemsNode, sameNode] = includes() - expect(itemsNode!.instances).toEqual({ total: 4, withErrors: 1 }) + expect(itemsNode!.instances).toEqual({ withErrors: 1 }) expect(itemsNode!.hasErrors).toBe(true) - expect(sameNode!.instances).toEqual({ total: 4, withErrors: 0 }) + expect(sameNode!.instances).toEqual({ withErrors: 0 }) expect(sameNode!.hasErrors).toBe(false) expect(query.errors.hasErrors).toBe(true) @@ -2019,8 +2302,16 @@ describe(`include instances`, () => { expect(retireEvents, `retiring a child in error relays`).toBeGreaterThan( 0, ) - expect(includes()[0]!.instances).toEqual({ total: 3, withErrors: 0 }) + expect(includes()[0]!.instances).toEqual({ withErrors: 0 }) expect(query.errors.hasErrors).toBe(false) + + // The tree's shape comes from the query, so a cleaned-up query keeps + // its include nodes' `instances`. + await query.cleanup() + expect(includes().map((node) => node.instances)).toEqual([ + { withErrors: 0 }, + { withErrors: 0 }, + ]) } finally { console.error = consoleError await query.cleanup() diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 86a81ff0f8..257ccab1e5 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1276,15 +1276,19 @@ export function queryCollectionOptions( // A failed fetch or result application after Collection readiness is a // failureReason, since TanStack Query fetches again. It clears once no // observed Query is failing. - const failingQueries = new Set() + // In failure order, so the latest failure still failing is last. + const failingQueries = new Map() const reportFailing = (hashedQueryKey: string, error: unknown) => { - failingQueries.add(hashedQueryKey) + failingQueries.delete(hashedQueryKey) + failingQueries.set(hashedQueryKey, error) if (runReady) reportError(error) } const reportRecovered = (hashedQueryKey: string) => { - if (!failingQueries.delete(hashedQueryKey) || failingQueries.size > 0) - return - if (runReady) reportHealthy() + if (!failingQueries.delete(hashedQueryKey) || !runReady) return + // The node shows a Query that is still failing, or nothing. + const [stillFailing] = [...failingQueries.values()].slice(-1) + if (failingQueries.size > 0) reportError(stillFailing) + else reportHealthy() } const exceptionalResultSettlements = new WeakMap< QueryObserverResult, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index da7f20fa9d..3d0f560b4f 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11780,3 +11780,77 @@ describe(`reload settles on the new run's first result`, () => { }, ) }) + +/** + * While any observed Query fails, the node's `failureReason` shows one that + * is still failing, the latest such failure. It clears once none fails. The + * lane fails two subsets' Queries in turn and recovers the one the node shows + * or the other one first. + */ +describe(`failureReason follows the still-failing Queries`, () => { + it.each([`shown`, `other`] as const)( + `recovering the %s Query first`, + async (first) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + const failing = new Set() + const collection = createCollection( + queryCollectionOptions({ + id: `failing-queries-${first}`, + queryKey: [`failing-queries`, first], + queryClient: client, + getKey: (row: { id: number }) => row.id, + syncMode: `on-demand`, + startSync: true, + queryFn: (ctx): Promise> => { + const limit = ( + ctx.meta?.loadSubsetOptions as { limit?: number } | undefined + )?.limit + return failing.has(limit!) + ? Promise.reject(new Error(`subset ${limit} failed`)) + : Promise.resolve([{ id: limit! }]) + }, + }), + ) + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + const refetch = async (limit: number) => { + const query = client + .getQueryCache() + .findAll() + .find( + (q) => + (q.meta?.loadSubsetOptions as { limit?: number } | undefined) + ?.limit === limit, + )! + await query.fetch().catch(() => undefined) + await flushPromises() + } + const shown = () => collection.errors.failureReason?.message ?? null + try { + await collection._sync.loadSubset({ limit: 1 }) + await collection._sync.loadSubset({ limit: 2 }) + await flushPromises() + failing.add(1) + await refetch(1) + failing.add(2) + await refetch(2) + expect(shown()).toBe(`subset 2 failed`) + const recovered = first === `shown` ? 2 : 1 + const remaining = first === `shown` ? 1 : 2 + failing.delete(recovered) + await refetch(recovered) + expect(shown(), `after one recovers`).toBe(`subset ${remaining} failed`) + failing.delete(remaining) + await refetch(remaining) + expect(shown(), `after both recover`).toBeNull() + } finally { + consoleError.mockRestore() + await collection.cleanup() + client.clear() + } + }, + ) +}) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 71d97ec550..0a73917e11 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -314,6 +314,21 @@ export function warnUnhashableDerivedIdentity( ) } +/** Status and error fields every `useLiveQuery` result has. */ +type UseLiveQueryFlags = { + isLoading: boolean + isReady: boolean + isIdle: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean + isCleanedUp: boolean + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: boolean + errors: ErrorTreeNode | undefined +} + /** * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch @@ -383,20 +398,6 @@ export function warnUnhashableDerivedIdentity( *
* ) */ -/** Status, persistence, and error fields every `useLiveQuery` result has. */ -type UseLiveQueryFlags = { - isLoading: boolean - isReady: boolean - isIdle: boolean - /** The first load failed: there is nothing to show. */ - isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ - isCrashed: boolean - isCleanedUp: boolean - /** The data is shown, and a sync problem or failed request is in `errors`. */ - isSyncError: boolean - errors: ErrorTreeNode | undefined -} // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 7d17111e09..0a7948523d 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -40,6 +40,21 @@ type InferConditionalResultType = ? InferResultType | [] : InferResultType +/** Status and error fields every `useLiveQuery` result has. */ +type UseLiveQueryFlags = { + isLoading: boolean + isReady: boolean + isIdle: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke; recovery is a new Collection. */ + isCrashed: boolean + isCleanedUp: boolean + /** The data is shown, and a sync problem or failed request is in `errors`. */ + isSyncError: boolean + errors: ErrorTreeNode | undefined +} + /** * Create a live query using a query function * @param queryFn - Query function that defines what data to fetch @@ -109,20 +124,6 @@ type InferConditionalResultType = * * ) */ -/** Status, persistence, and error fields every `useLiveQuery` result has. */ -type UseLiveQueryFlags = { - isLoading: boolean - isReady: boolean - isIdle: boolean - /** The first load failed: there is nothing to show. */ - isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ - isCrashed: boolean - isCleanedUp: boolean - /** The data is shown, and a sync problem or failed request is in `errors`. */ - isSyncError: boolean - errors: ErrorTreeNode | undefined -} // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( From 846c0f54de15e4807e0ae9723a9744dde594ccca Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 10:45:18 -0600 Subject: [PATCH 070/106] fix(db): clear an Effect's request error when its failed demand is released As for a live query's retired demand, an Effect's lazy-join request error clears once the demand needs none of its rows. A release that itself fails during that update still shows. A new Effect lane failed before the change. Main's new ownership-lifecycle tests (#2198, #2206) expected status error after a failed persisted commit. Under this branch a local persistence failure crashes the Collection, so they now expect crashed, with their prose. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/query/effect.ts | 10 +++ .../db/tests/source-health-oracle.test.ts | 61 +++++++++++++++++++ .../tests/ownership-lifecycle.oracle.test.ts | 10 +-- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 924318ad30..8e58f59166 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads and overlapping Effect join loads (a request error clears only when a load started after the failure succeeds; 16 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. Open: the Effect keeps lazy-demand request errors per source rather than per demand; on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads and overlapping Effect join loads (a request error clears only when a load started after the failure succeeds; 16 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. An Effect lane checks that releasing a failed lazy demand clears its request error, and a joined-filter lane checks that a failed join load lets an ordered window move again. Open: the Effect keeps lazy-demand request errors per source, not per demand, so a demand that only shrinks clears its error by the load-order rule, not by which keys failed; on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 3f73b028e2..1e50f99a26 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -882,6 +882,8 @@ class EffectPipelineRunner { keys: Set, ): void { let update + // A release can fail during this update; only an earlier failure clears. + const problemBefore = this.requests.problem try { update = this.demand.setDemand(subscription, plan, keys) } catch (error) { @@ -892,6 +894,14 @@ class EffectPipelineRunner { if (this.starting) throw error return } + // A released demand needs none of its rows, so its earlier failure no + // longer stands, as for a live query's retired demand. + if ( + update.changed && + update.empty && + this.requests.problem === problemBefore + ) + this.settleLoad(sourceId) if (update.ready instanceof Promise) { this.trackInitialLoad( update.ready, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 50b8b5f26e..0c5c818129 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1913,6 +1913,67 @@ describe(`overlapping Effect loads`, () => { ) }) +// As for a live query, an Effect's request error clears when the demand +// that failed is released: its rows are no longer needed. +it(`clears an Effect's request error when the failed demand is released`, async () => { + let failChildren = false + const parents = createSource(`B`) + const children = createCollection({ + id: `health-effect-release-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => + failChildren + ? Promise.reject(new Error(`child load failed`)) + : Promise.resolve(), + } + }, + }, + }) + const trees: Array = [] + parents.collection.startSyncImmediate() + const writeParent = (id: number, type: `insert` | `delete` = `insert`) => { + parents.params.begin() + parents.params.write({ type, value: { id, parentId: id } }) + parents.params.commit() + } + const consoleError = console.error + console.error = () => {} + const effect = createEffect({ + query: (q) => + q + .from({ parent: parents.collection }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + onBatch: () => {}, + onErrorsChange: (errors) => trees.push(errors), + }) + try { + parents.params.markReady() + await flushPromises() + failChildren = true + writeParent(1) + await flushPromises() + expect(trees.at(-1)?.error?.message).toBe(`child load failed`) + writeParent(1, `delete`) + await flushPromises() + expect(trees.at(-1)?.error ?? null, `after the demand is released`).toBe( + null, + ) + } finally { + console.error = consoleError + await effect.dispose() + await parents.collection.cleanup() + await children.cleanup() + } +}) + // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index df36fc1e6a..8ba0d64d37 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -6483,7 +6483,8 @@ describe(`query collection ownership lifecycle`, () => { * resources). * * Reference: the model owns one row through one demand. A failed release - * leaves `{ rows: [shared], stored: [shared], status: error }`; a restarted + * is a local persistence failure, so it crashes the Collection and leaves + * `{ rows: [shared], stored: [shared], status: crashed }`; a restarted * load and release give `[shared]` and then `{ rows: [], stored: [] }`. The * `durable` history is the control: the release succeeds and the restarted * run never refetches, because no demand remains. @@ -6575,7 +6576,7 @@ describe(`query collection ownership lifecycle`, () => { queryCalls: queryFn.mock.calls.length, }).toEqual({ afterRelease: failed - ? { rows: [shared.id], stored: [shared.id], status: `error` } + ? { rows: [shared.id], stored: [shared.id], status: `crashed` } : { rows: [], stored: [], status: `ready` }, reacquisition: history === `failed-then-reacquired` @@ -6593,7 +6594,8 @@ describe(`query collection ownership lifecycle`, () => { * fails. The release's commit is held at the persistence boundary while the * Query is reacquired; the reacquired application's commit then fails. The * release is durable, so the row leaves; the reacquired result never became - * durable, so it cannot keep the row. The Collection enters `error`, the + * durable, so it cannot keep the row. The persistence failure crashes the + * Collection, the * acquisition rejects with the persistence failure, and a restart serves and * releases the row normally. A history that keeps the row public after the * failure would report a result that storage never accepted. @@ -6678,7 +6680,7 @@ describe(`query collection ownership lifecycle`, () => { }).toEqual({ held: { rows: [shared.id], status: `ready` }, reacquired: `Failed to durably persist collection "${id}": storage failed`, - failed: { rows: [], status: `error` }, + failed: { rows: [], status: `crashed` }, restartedLoad: [shared.id], restartedRelease: { rows: [], stored: [] }, }) From e5d7b3a7c03da0d55c15d44660b0ed68cf8f3a34 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 10:46:58 -0600 Subject: [PATCH 071/106] chore: tell custom adapters to keep run readiness in sync Co-authored-by: Isaac --- .changeset/source-health-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index a38f30d7e4..9e026a11da 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -39,4 +39,4 @@ Breaking changes and migration: | query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.errors`, `reload()` | | infinite-query hooks' `error` (React, Vue, Svelte) | the root node's `error` in `errors`; `fetchNextPage()` still resolves | -`reportError()` and `reportHealthy()` throw before the first `markReady()`. `markReady()` after a failed first load throws. Calls after a crash do nothing. `useLiveSuspenseQuery` now throws the first load's original cause instead of a generic error. +Custom sync adapters: keep the run's readiness in your `sync` function. A reload calls `sync` again while `collection.status` still shows the previous run's status, and a reload replaces every row at the new run's first commit, so write a full snapshot then. `reportError()` and `reportHealthy()` throw before the first `markReady()`. `markReady()` after a failed first load throws. Calls after a crash do nothing. `useLiveSuspenseQuery` now throws the first load's original cause instead of a generic error. From a845331cf689e94e3958923386393eaf1c9eff29 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 11:39:32 -0600 Subject: [PATCH 072/106] refactor(db): one recovery path for a failed query; drop include roll-up Code-weight cuts with no change to the remaining guarantees: - A live query in `error` now leaves it only through its own reload(): the builder's auto-restart on source recovery (failedBySource, restartFromError, the showLoading reload option, and error->loading) is gone. query.reload() already reloads every failed Collection below the query and then the query itself, so one path covers both. - The include `instances` roll-up is gone: per-row child Collections are not in the error tree, and the bucket facade no longer relays their problems. - isInErrorState merged into fatalQueryError; they always held the same value. The source-health model now leaves `error` only by the query's own reload, and the include lane keeps the shape and no-silent-change laws. Docs and the changeset follow. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 4 +- docs/contributing/glossary.md | 2 +- docs/guides/error-handling.md | 13 +- packages/db/mangle-cache.json | 5 - packages/db/src/collection/error-tree.ts | 38 +---- packages/db/src/collection/lifecycle.ts | 19 +-- packages/db/src/collection/sync.ts | 5 +- packages/db/src/query/live/ARCHITECTURE.md | 10 +- .../src/query/live/bucket-facade-adapter.ts | 29 ---- .../query/live/collection-config-builder.ts | 68 ++------- packages/db/src/types.ts | 2 - .../db/tests/source-health-oracle.test.ts | 144 ++++++------------ 12 files changed, 77 insertions(+), 262 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 9e026a11da..e29ce46335 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -17,7 +17,7 @@ Collections now answer two questions separately: can the data be shown (`status` - `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It is permanent for the instance and reaches every live query and Effect above it. `error` now means only "the first load failed". - Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. -- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` restarts by itself only when the source whose failed first load caused it becomes ready. +- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. - Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. @@ -33,7 +33,7 @@ Breaking changes and migration: | `cleanup()` then `preload()` to recover | `reload()` | | `cleanup()` while live queries or Effects read the Collection | rejects; unmount first or pass `{ dependents: true }` | | `collection.health`, `health:change` | `collection.errors`, `errors:change` | -| node `phase`, `final`, `isError`, `instances.inError` | node `failureReason` or `error`, `instances.withErrors` | +| node `phase`, `final`, `isError` | node `failureReason` or `error` | | Effect `onHealthChange` | `onErrorsChange` | | `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()`, `PERSISTED_READINESS` | `status` and `errors` | | query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.errors`, `reload()` | diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 1039a370fd..9df2becea8 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -74,7 +74,7 @@ until cleanup ends the run. | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | | request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Sync error or request data. | | error tree | The `errors` value of a Collection, live query, or Effect: one node for a Collection; for a query, one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `errors:change` announces a change. | An error list. | -| error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. An include node summarizes all its per-row child Collections in `instances`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | +| error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | ## Persistence coordination terms diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 43ed7e6b90..bd5ca979c3 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -317,9 +317,9 @@ too: when `isLoadingError` is true, the first node it returns holds the first load failure in its `error`. A Collection used twice in one query, for example in a self-join, has one node -per alias. An include has one node for all of its per-row child Collections, -with `instances: { withErrors }`, the number of them that have a problem. A nested live query's node carries its -own sources. +per alias. An include has one node, which shows the include's source; its +per-row child Collections are not in the tree. A nested live query's node +carries its own sources. ### Collections without a live query @@ -368,9 +368,8 @@ sync-side truncate: nothing visible changes until the new run's first commit. On a live query, `reload()` reloads each of its sources that has an `error`, then the query itself if it failed its first load. To reload one source -instead, call `reload()` on that source's Collection. When a failed source -becomes ready and no other source is in `error` or `crashed`, a query in -`error` restarts by itself and shows `loading` until it is ready. +instead, call `reload()` on that source's Collection; a query in `error` +stays there until its own `reload()`. ```ts // Retry a failed first load every 30 seconds until the backend is back. @@ -540,7 +539,7 @@ sync: ({ begin, write, commit, markReady, reportError, fail, reportHealthy }) => | `markError(error)` | `fail(error)` | | `reportError(error, { final })` | `reportError(error)` or `fail(error)` | | node `phase`, `final`, `isError` | node `failureReason` or `error` | -| node `instances.inError` | `instances.withErrors` | +| node `instances` | removed | | `cleanup()` then `preload()` to recover | `reload()` | | `cleanup()` with live queries mounted | rejects; unmount first or `cleanup({ dependents: true })` | | `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()` | `status` and `errors` | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 12c430b350..f11b54c61b 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -118,7 +118,6 @@ "sentToD2RowsBySource": "b3", "dispatching": "b4", "cleanupPromise": "b5", - "isInErrorState": "b6", "orderedLoaders": "b7", "retainedWindow": "b8", "setReadyIfIdle": "b9", @@ -398,7 +397,6 @@ "reportFailedRequest": "gJ", "includeSourceIds": "gL", "latchReady": "gO", - "bucketFacades": "gP", "reportedTree": "gQ", "settleLoad": "gR", "endedProblem": "gS", @@ -408,7 +406,6 @@ "assertAdapterActive": "bI", "statusError": "cI", "stoppedError": "eN", - "onInstanceErrorsChange": "e8", "statusAllowsError": "gI", "reportErrors": "gK", "enterErrorState": "gM", @@ -430,7 +427,6 @@ "graphHasProcessedInput": "dk", "windowIsInsideOwnPrefix": "gE", "failureReasonFromReload": "g5", - "failedBySource": "g6", "loadOrders": "g8", "endReload": "g9", "loadOrder": "ha", @@ -438,6 +434,5 @@ "cancelSyncTransaction": "g7", "startedBeforeFailure": "hb", "publishedErrors": "hc", - "instancedSourceIds": "hd", "views": "he" } diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/error-tree.ts index 436d153d66..b6669bbc04 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/error-tree.ts @@ -9,10 +9,6 @@ export interface ErrorTreeSource { readonly id?: string readonly collection: CollectionImpl readonly kind?: ErrorTreeNode[`kind`] - /** An include's per-row child Collections, for its `instances` roll-up. */ - readonly instances?: () => ReadonlyArray< - CollectionImpl - > } // One cached node per Collection and source. A node keeps its identity until @@ -28,7 +24,7 @@ const nodeCache = new WeakMap>() */ export function buildErrorTree( collection: CollectionImpl, - { alias, id, kind, instances }: Omit = { + { alias, id, kind }: Omit = { alias: `root`, }, // The reader that caches this node, so a source's node lives only as long @@ -50,7 +46,6 @@ export function buildErrorTree( failureReason, error, reloading: collection._lifecycle.reloading, - ...(instances ? { instances: countInstances(instances()) } : {}), sources: children, }) } @@ -64,49 +59,27 @@ export function errorTreeNode( id: string | undefined, fields: Omit, ): ErrorTreeNode { - const { failureReason, error, instances, sources } = fields + const { failureReason, error, sources } = fields return cachedNode(owner, id ?? fields.alias, { ...fields, hasErrors: failureReason !== null || error !== null || - (instances?.withErrors ?? 0) > 0 || sources.some((child) => child.hasErrors), }) } -/** Whether a Collection's own node shows a problem. */ -export function hasOwnProblem( - collection: Pick, `_lifecycle`>, -): boolean { - const { failureReason, error } = collection._lifecycle.problems - return failureReason !== null || error !== null -} - /** * Every node in an error tree that shows a problem of its own, in tree order: - * a `failureReason`, an `error`, or include instances with problems. A node - * whose only problems are below it is not listed; its descendants are. + * a `failureReason` or an `error`. A node whose only problems are below it is + * not listed; its descendants are. */ export function findProblems(tree: ErrorTreeNode): Array { if (!tree.hasErrors) return [] - const ownProblem = - tree.failureReason !== null || - tree.error !== null || - (tree.instances?.withErrors ?? 0) > 0 + const ownProblem = tree.failureReason !== null || tree.error !== null return [...(ownProblem ? [tree] : []), ...tree.sources.flatMap(findProblems)] } -function countInstances( - collections: ReadonlyArray>, -): { withErrors: number } { - let withErrors = 0 - for (const instance of collections) { - if (hasOwnProblem(instance)) withErrors++ - } - return { withErrors } -} - /** Return the cached node for this Collection and alias if nothing changed. */ function cachedNode( owner: object, @@ -130,7 +103,6 @@ function sameNode(a: ErrorTreeNode, b: ErrorTreeNode): boolean { a.failureReason === b.failureReason && a.error === b.error && a.reloading === b.reloading && - a.instances?.withErrors === b.instances?.withErrors && a.sources.length === b.sources.length && a.sources.every((source, index) => source === b.sources[index]) ) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index bf0878a285..3b58386364 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -130,7 +130,7 @@ export class CollectionLifecycleManager< // problem is error tree detail. ready: [`cleaned-up`, `crashed`], // A failed first load is final for its sync run. Only a reload leaves - // it for `ready` or `loading` (see `recovering`). + // it for `ready` (see `recovering`). error: [`cleaned-up`, `idle`, `crashed`], // A crash is permanent for the Collection instance. crashed: [`cleaned-up`], @@ -139,11 +139,7 @@ export class CollectionLifecycleManager< if ( !validTransitions[from].includes(to) && - !( - this.recovering && - from === `error` && - (to === `ready` || to === `loading`) - ) + !(this.recovering && from === `error` && to === `ready`) ) { throw new InvalidCollectionStatusTransitionError(from, to, this.id) } @@ -366,17 +362,6 @@ export class CollectionLifecycleManager< run.resolve() } - /** A live query whose sources recovered starts again, shown `loading`. */ - public restartFromError(): void { - this.statusError = null - this.recovering = true - try { - this.setStatus(`loading`) - } finally { - this.recovering = false - } - } - /** A reload retired the sync run; subscriptions detach its demand. */ public retireSubscriptionDemand(): void { for (const subscription of this.changes.changeSubscriptions) { diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 02dbe8de9e..64f3478e1d 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -211,7 +211,7 @@ export class CollectionSyncManager< * its first `markReady()` replaces them with none. A reload in flight * returns its promise; a crashed Collection cannot reload. */ - public reload(options: { showLoading?: boolean } = {}): Promise { + public reload(): Promise { const status = this.lifecycle.status if (status === `crashed`) { return Promise.reject(new CollectionReloadCrashedError(this.id)) @@ -230,9 +230,6 @@ export class CollectionSyncManager< // truncate replays it through the new run's loader. this.lifecycle.retireSubscriptionDemand() this.state.reloadTruncatePending = true - if (options.showLoading && status === `error`) { - this.lifecycle.restartFromError() - } try { this.enterSync(syncCallbackEpoch) } catch (error) { diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 24ef9fba31..50e23e92c7 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1710,13 +1710,9 @@ every row before the top-K (a recorded limit). `failureReason` (the sync adapter still tries) or `error` (the first load failed, the sync adapter stopped, the Collection crashed, or, on a query's own node, a request for more data failed). An include's source - has `kind: 'include'`; when the include materializes a child Collection - per parent row, its node rolls those up as `instances: { withErrors }`, - counting only that include's children with a problem even when another - include reads the same alias, and a child's change of problem is relayed - like a source's. The roll-up has no child count: every change to the tree - is announced by `errors:change`, so readers can keep a tree until the next - event, and adding a healthy child changes nothing. A pooled live query's tree is the tree a live-query Collection + has `kind: 'include'`; its per-row child Collections are not in the tree, + so adding a child changes nothing. Every change to the tree is announced + by `errors:change`, so readers can keep a tree until the next event. A pooled live query's tree is the tree a live-query Collection over its source would have, so it also reaches deeper levels. The shape is fixed by the query. Each node mirrors its Collection when read, and the query emits `errors:change` whenever a node below it changes. When a diff --git a/packages/db/src/query/live/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index 15980ef04d..24720ed922 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -7,7 +7,6 @@ import { transformPublicContainers, } from '../compiler/route-metadata.js' import { codedMessage, devBuild } from '../../error-message.js' -import { hasOwnProblem } from '../../collection/error-tree.js' import { BUCKET_FACADE_REF } from './materialized-pipeline.js' import type { Collection } from '../../collection/index.js' import type { SyncConfig } from '../../types.js' @@ -34,8 +33,6 @@ type FacadeEntry = { dispose: () => Promise sync: FacadeSync | undefined order: WeakMap - /** Whether the include node counts this facade as in error. */ - readonly withError: boolean } type SnapshotRow = { @@ -82,8 +79,6 @@ export class BucketFacadeAdapter { private readonly parentId: string, private readonly compilations: Array, onMessages: (count: number) => void, - // A per-row child Collection's health is part of the parent's error tree. - private readonly onInstanceErrorsChange: () => void = () => {}, ) { for (const compilation of compilations) { compilation.rows.pipe( @@ -223,16 +218,6 @@ export class BucketFacadeAdapter { return this.resolveValue(value) as T } - /** The live per-row Collections of the includes that read this source. */ - instancesFor(sourceId: string): Array> { - return this.compilations - .filter((compilation) => compilation.sourceIds.includes(sourceId)) - .flatMap((compilation) => [ - ...(this.entries.get(compilation.edgeId)?.values() ?? []), - ]) - .map((entry) => entry.collection) - } - cleanup(): void { this.cleanedUp = true for (const byBucket of this.entries.values()) { @@ -425,8 +410,6 @@ export class BucketFacadeAdapter { if (byBucket!.size === 0) this.entries.delete(edgeId) const retired = getOrCreate(this.retiredEntries, edgeId, () => new Map()) retired.set(bucketKey, entry) - // A retired facade leaves the include node's count. - if (entry.withError) this.onInstanceErrorsChange() } private getEntry(edgeId: string, bucketKey: string): FacadeEntry { @@ -471,15 +454,6 @@ export class BucketFacadeAdapter { startSync: true, gcTime: 0, }) - // The include node counts the children with a problem, so only a change - // in that state concerns the parent; startup status changes do not. - let withError = false - collection.on(`errors:change`, () => { - const next = hasOwnProblem(collection) - if (next === withError) return - withError = next - this.onInstanceErrorsChange() - }) // The adapter retires its facades itself, even while readers subscribe. const dispose = () => collection._lifecycle.cleanup() // Other parent rows can share this facade, so a holder must not end it. @@ -497,9 +471,6 @@ export class BucketFacadeAdapter { get sync() { return sync }, - get withError() { - return withError - }, order, } byBucket.set(bucketKey, entry) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 9b870548fd..e397ce39b6 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -123,10 +123,7 @@ export class CollectionConfigBuilder< public currentSyncState: FullSyncState | undefined // Error state tracking - private isInErrorState = false private fatalQueryError = false - // A source's failed first load, not this query's own, ended the first load. - private failedBySource = false private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions @@ -150,8 +147,8 @@ export class CollectionConfigBuilder< findProblems(collection.errors).some((node) => node.error !== null), ) await Promise.all(failed.map((collection) => collection.reload())) - // A recovered source may have restarted this query already; the reload - // settles with that run. + // The query reloads itself after its sources; a reload of it already in + // flight settles with that run. if (query?.status === `error` || query?._lifecycle.reloading) await query._sync.reload() } @@ -199,12 +196,8 @@ export class CollectionConfigBuilder< private pipelineCache: ResultStream | undefined public sourceWhereClausesCache: Map> | undefined - // Include sources with per-row child Collections. The query decides this, - // so it outlives the compiled cache that cleanup resets. - private readonly instancedSourceIds = new Set() private bucketFacadesCache: ReturnType[`facades`] | undefined - private bucketFacades: BucketFacadeAdapter | undefined private readonly includeSourceIds = new Set() // Map of opaque source ID to subscription @@ -495,22 +488,17 @@ export class CollectionConfigBuilder< /** The source aliases of this query, in query order, for its error tree. */ errorTreeSources(): ReadonlyArray { - return this.collectionSources.map(({ sourceId, alias, collection }) => { - const source = { id: sourceId, alias, collection } - if (!this.includeSourceIds.has(sourceId)) return source - const instanced = this.instancedSourceIds.has(sourceId) - return { - ...source, - kind: `include` as const, - ...(instanced - ? { - instances: () => this.bucketFacades?.instancesFor(sourceId) ?? [], - } - : {}), - } - }) + return this.collectionSources.map(({ sourceId, alias, collection }) => ({ + id: sourceId, + alias, + collection, + ...(this.includeSourceIds.has(sourceId) + ? { kind: `include` as const } + : {}), + })) } + settleDemand(planId: string, generation: number): void { const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation || demand.settled) return @@ -736,7 +724,7 @@ export class CollectionConfigBuilder< this.currentSyncState === syncState // Don't run if the live query is in an error state - if (this.isInErrorState) { + if (this.fatalQueryError) { return } @@ -848,9 +836,7 @@ export class CollectionConfigBuilder< // Store reference to the live query collection for error state transitions this.liveQueryCollection = config.collection // Reset error state from any previous sync run so a restarted sync can become ready again. - this.isInErrorState = false this.fatalQueryError = false - this.failedBySource = false this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config @@ -931,9 +917,7 @@ export class CollectionConfigBuilder< this.loadMoreFn = undefined this.currentWindow = undefined this.settledWindow = this.initialWindow - this.isInErrorState = false this.fatalQueryError = false - this.failedBySource = false // Reset caches so a fresh graph/pipeline is compiled on next start // This avoids reusing a finalized D2 graph across GC restarts @@ -1000,9 +984,6 @@ export class CollectionConfigBuilder< this.pipelineCache = materialized.pipeline this.sourceWhereClausesCache = compilation.sourceWhereClauses this.bucketFacadesCache = materialized.facades - for (const compilation of materialized.facades ?? []) - for (const sourceId of compilation.sourceIds) - this.instancedSourceIds.add(sourceId) const missingSources = this.collectionSources .map((source) => source.sourceId) @@ -1057,10 +1038,8 @@ export class CollectionConfigBuilder< (count) => { syncState.messagesCount += count }, - () => this.liveQueryCollection?._emitErrorsChange(), ) : undefined - this.bucketFacades = bucketFacades if (bucketFacades) { syncState.unsubscribeCallbacks.add(() => bucketFacades.cleanup()) } @@ -1258,7 +1237,6 @@ export class CollectionConfigBuilder< // node: the query keeps its status and rows. if (status === `error`) { if (this.liveQueryCollection?.status === `ready`) return - this.failedBySource = true this.failQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Source collection '${collectionId}' failed its first load` @@ -1275,25 +1253,6 @@ export class CollectionConfigBuilder< return } - // A query whose first load failed because a source failed starts a new - // sync run once no source has a failure left, for example after the - // source reloaded. It shows `loading` until that run is ready. - if ( - status === `ready` && - this.failedBySource && - this.liveQueryCollection?.status === `error` && - this.collectionSources.every( - ({ collection }) => - collection.status !== `error` && collection.status !== `crashed`, - ) - ) { - this.failedBySource = false - void this.liveQueryCollection._sync - .reload({ showLoading: true }) - .catch(() => undefined) - return - } - if (status === `ready`) { if ( Object.values(this.optimizableOrderByCollections).some( @@ -1327,7 +1286,7 @@ export class CollectionConfigBuilder< const { markReady } = config // Don't update status if already in error - if (this.isInErrorState) { + if (this.fatalQueryError) { return } @@ -1371,7 +1330,6 @@ export class CollectionConfigBuilder< private enterErrorState(message: string): boolean { if (this.fatalQueryError) return false this.fatalQueryError = true - this.isInErrorState = true console.error( devBuild() && process.env.NODE_ENV !== `production` ? `[Live Query Error] ${message}` diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 16fd182d2f..6b4f378552 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -819,8 +819,6 @@ export interface ErrorTreeNode { * are the previous run's until it commits or fails. */ readonly reloading: boolean - /** For an include node: how many of its per-row child Collections have a problem. */ - readonly instances?: { readonly withErrors: number } readonly sources: ReadonlyArray } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 0c5c818129..2500c586ba 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -30,8 +30,7 @@ * a node keeps its identity until something in its subtree changes, and a * change replaces only the nodes on its path. * - * An include's source node has `kind: 'include'` and rolls up the include's - * per-row child Collections as `instances: { withErrors }`. A pooled + * An include's source node has `kind: 'include'`. A pooled * live query shows its source's status and the same tree a live-query * Collection over that source would have, so its tree also reaches through * a nested live query. @@ -69,11 +68,10 @@ * Collection crashes and the reload ends with it, unless a reload is already in * flight and nothing is retired. `cancel` begins, writes, and commits with an * aborted signal: nothing changes, and a reload's truncate waits for the run's - * next commit. A query whose first load failed - * starts again once no source has a failure, `loading` with `reloading` - * until it is ready. `reload-query` calls the observed query's `reload()`, - * which reloads every Collection below it that shows an error, through a - * nested live query. `restart` cleans up the + * next commit. A query whose first load failed stays in `error` until its + * own reload: `reload-query` calls the observed query's `reload()`, which + * reloads every Collection below it that shows an error, through a nested + * live query, and then the query itself. `restart` cleans up the * live query and every Collection, then preloads the live query again. Every step * is issued in every state. The model predicts its outcome, including a throw * for `markReady()` after `fail()`, for any report after `fail()`, and for a @@ -103,9 +101,7 @@ * Collections are eager, so a live query cannot become ready while a Collection is * still loading. The on-demand case, where a Collection fails its first load * after the live query is ready, is not generated here. Per-row child - * Collections are rolled up, not nodes, and no grammar step fails one; the - * include-instances lane fails one and checks the roll-up, the relay, and - * that two includes under one alias count separately. An Effect's request + * Collections are not in the tree; an include's node shows its source. An Effect's request * error clears on a later successful load of the same Collection; release of * only the failed demand is not exercised. Pooled histories do not restart: a pooled view ends for good when * its Collection is cleaned up. The ordering check reads the observed query @@ -121,7 +117,6 @@ import { Query } from '../src/query/builder/index.js' import { eq, isUndefined, not } from '../src/query/builder/functions.js' import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import { RequestErrors } from '../src/collection/lifecycle.js' -import { BasicIndex } from '../src/indexes/basic-index.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { createDeferred } from '../src/deferred.js' import { oraclePropertyOptions } from './oracle-config.js' @@ -222,10 +217,10 @@ function freshModel(): Model { // A live query's status follows its sources until it is ready, and never // leaves `crashed`. A crashed input crashes the query at any time, even after // its first load failed, as a crash does to a Collection; after the query is -// ready, nothing else changes it. A failed first load stays until no input -// has a failure left (inputs leave `error` only by reloading): then the query -// starts a new sync run, `loading` with `reloading` until it is ready. The -// query takes the cause of the input that ended its sync run. +// ready, nothing else changes it. A failed first load stays until the query's +// own reload (see settleSelfReloads): that run keeps `error` with `reloading` +// until it is ready, or until an input fails it with a new cause. The query +// takes the cause of the input that ended its sync run. function deriveGate( current: GateModel, inputs: ReadonlyArray, @@ -236,10 +231,7 @@ function deriveGate( return { gate: `crashed`, cause: crashed.cause, reloading: false } if (current.gate === `ready`) return current const failed = inputs.find(({ gate }) => gate === `error`) - if (current.gate === `error`) { - if (failed) return current - current = { gate: `loading`, cause: null, reloading: true } - } + if (current.gate === `error` && !current.reloading) return current if (failed) return { gate: `error`, cause: failed.cause, reloading: false } if (inputs.every(({ gate }) => gate === `ready`)) return { gate: `ready`, cause: null, reloading: false } @@ -469,7 +461,6 @@ type ExpectedNode = { failureReason: ExpectedProblem error: ExpectedProblem reloading: boolean - instances?: { withErrors: number } sources: Array } @@ -528,9 +519,7 @@ function queryNode( // problem; a node whose problems are all below it is skipped. type ProblemSummary = [alias: string, message: string] function expectedProblems(node: ExpectedNode): Array { - const own = - (node.error ?? node.failureReason)?.message ?? - ((node.instances?.withErrors ?? 0) > 0 ? `instances` : null) + const own = (node.error ?? node.failureReason)?.message ?? null return [ ...(own ? [[node.alias, own] as ProblemSummary] : []), ...node.sources.flatMap(expectedProblems), @@ -539,8 +528,7 @@ function expectedProblems(node: ExpectedNode): Array { function problemOf(node: ErrorTreeNode): ProblemSummary { return [ node.alias, - (node.error ?? node.failureReason)?.message.replace(cleanupPrefix, ``) ?? - `instances`, + (node.error ?? node.failureReason)!.message.replace(cleanupPrefix, ``), ] } @@ -568,16 +556,11 @@ function expectedTree( source(`b`, B), ]) case `include`: - // The include node rolls up the per-row child Collections: one per - // parent row the sync run received. They stay until a restart, even - // when the sync run fails later. No grammar step fails a child - // Collection, so none is in error. + // The include node shows its source; per-row child Collections are not + // in the tree. return queryNode(`root`, model.query, [ source(`b`, B), - { - ...sourceNode(`a`, A, identity, `include`), - instances: { withErrors: 0 }, - }, + sourceNode(`a`, A, identity, `include`), ]) case `pooled-nested`: // The view's node shows its source's gate, and the source's subtree @@ -731,7 +714,6 @@ function project(node: ErrorTreeNode): ExpectedNode { failureReason: problem(node.failureReason), error: problem(node.error), reloading: node.reloading, - ...(node.instances ? { instances: node.instances } : {}), sources: node.sources.map(project), } } @@ -1163,9 +1145,9 @@ describe(`source failure and health oracle`, () => { // invariant directly: after the reload no transaction stays open, and the // new run's own commit decides the row. describe(`reload invariant`, () => { - // A query's reload() settles with the query: when a failed source - // recovers, the query restarts, and the reload waits for that run to be - // ready (or to fail) instead of resolving while the query is `loading`. + // A query's reload() settles with the query: after its failed source + // recovers, the query reloads itself, and the reload waits for that run to + // be ready (or to fail). it(`settles a query reload with the query's restarted run`, async () => { let runs = 0 const loads: Array>> = [] @@ -1221,9 +1203,13 @@ describe(`reload invariant`, () => { settled = true }) await flushPromises() - // The restarted query waits for its join load, so the reload waits too. - expect(query.status).toBe(`loading`) - expect(settled, `reload settled while the query was loading`).toBe(false) + // The query's own reload waits for its join load: it keeps `error` and + // shows `reloading`, and the reload waits too. + expect(query.status).toBe(`error`) + expect(query.errors.reloading).toBe(true) + expect(settled, `reload settled before the query's run was ready`).toBe( + false, + ) for (const load of loads) load.resolve() await reload expect(query.status).toBe(`ready`) @@ -1478,11 +1464,10 @@ describe(`request errors`, () => { })) }) -// A query restarts by itself only when the source whose failure ended its -// first load becomes ready. A query whose own load failed waits for -// `reload()`: another source becoming ready does not restart it, and its -// failed load is not retried. The main grammar fails only sources, so this -// lane supplies the query's own failure. +// A query whose first load failed waits for `reload()`: another source +// becoming ready does not restart it, and its failed load is not retried. The +// main grammar fails only sources, so this lane supplies the query's own +// failure. describe(`a query's own failed first load`, () => { it(`does not restart when an unrelated source becomes ready`, async () => { let loads = 0 @@ -2258,17 +2243,13 @@ describe(`effect request errors`, () => { }) /** - * An include node rolls up the include's own per-row child Collections. A - * child Collection that fails, here because an index the reader created on - * it throws on a new row, counts in `inError` and makes the include node - * `hasErrors`; the live query emits `errors:change` for it and keeps its own - * status. Two includes that read one Collection under the same alias are - * two nodes, and each counts only its own children. A healthy child that - * starts does not change any node's error state, so it emits no - * `errors:change`; otherwise every new parent row would rebuild the tree. + * Two includes that read one Collection under the same alias are two include + * nodes, and the query fixes that shape, so a cleaned-up query keeps them. A + * per-row child Collection that starts changes nothing in the tree, so it + * emits no `errors:change`; otherwise every new parent row would rebuild it. */ -describe(`include instances`, () => { - it(`a failing child Collection counts in its own include only`, async () => { +describe(`include nodes`, () => { + it(`keep their shape, and healthy children change nothing`, async () => { const make = (name: string) => { let params!: SyncParams const collection = createCollection({ @@ -2310,9 +2291,9 @@ describe(`include instances`, () => { A.ready() await preload const includes = () => query.errors.sources.slice(1) - expect(includes().map((node) => [node.kind, node.instances])).toEqual([ - [`include`, { withErrors: 0 }], - [`include`, { withErrors: 0 }], + expect(includes().map((node) => [node.alias, node.kind])).toEqual([ + [`a`, `include`], + [`a`, `include`], ]) let events = 0 @@ -2325,53 +2306,16 @@ describe(`include instances`, () => { B.write({ id: 3, parentId: 3 }) B.write({ id: 4, parentId: 4 }) await flushPromises() + stop() expect(events, `healthy children emit no errors:change`).toBe(0) expect(query.errors, `no tree change without an event`).toBe(treeBefore) - expect(includes()[0]!.instances).toEqual({ withErrors: 0 }) - // One child Collection of `items` fails: its index throws on row 9. - class FailingIndex extends BasicIndex { - override add(key: number, item: any): void { - if (key === 9) throw new Error(`index failed`) - super.add(key, item) - } - } - const items = query.get(1)!.items - items.createIndex((row) => row.id, { indexType: FailingIndex }) - expect(() => A.write({ id: 9, parentId: 1 })).toThrow(`index failed`) - await flushPromises() - stop() - - expect(items.status).toBe(`crashed`) - expect(query.status).toBe(`ready`) - expect(events, `errors:change relay`).toBeGreaterThan(0) - const [itemsNode, sameNode] = includes() - expect(itemsNode!.instances).toEqual({ withErrors: 1 }) - expect(itemsNode!.hasErrors).toBe(true) - expect(sameNode!.instances).toEqual({ withErrors: 0 }) - expect(sameNode!.hasErrors).toBe(false) - expect(query.errors.hasErrors).toBe(true) - - // Removing the failing child's parent row retires that child, so it - // leaves the count and the query reports the change. - let retireEvents = 0 - const stopRetire = query.on(`errors:change`, () => { - retireEvents++ - }) - B.write({ id: 1, parentId: 1 }, `delete`) - await flushPromises() - stopRetire() - expect(retireEvents, `retiring a child in error relays`).toBeGreaterThan( - 0, - ) - expect(includes()[0]!.instances).toEqual({ withErrors: 0 }) - expect(query.errors.hasErrors).toBe(false) // The tree's shape comes from the query, so a cleaned-up query keeps - // its include nodes' `instances`. + // its include nodes. await query.cleanup() - expect(includes().map((node) => node.instances)).toEqual([ - { withErrors: 0 }, - { withErrors: 0 }, + expect(includes().map((node) => node.kind)).toEqual([ + `include`, + `include`, ]) } finally { console.error = consoleError From 35ed3377ce4af2f418bc2898ee217d39c48c9f51 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 13:05:42 -0600 Subject: [PATCH 073/106] refactor(db): drop Effect request errors; a failed load ends an Effect An Effect again ends through onSourceError when a load it needs fails, as on main, because its incremental result can no longer be complete. This removes the Effect's RequestErrors and LoadOrder bookkeeping and its demand-release clearing. Sync problems on its sources after its gate still reach onErrorsChange, and the Effect keeps running. The oracle's Effect request lanes now check that a failed load after the gate ends the Effect; the overlapping and released-demand Effect lanes are gone with the guarantee. Effect test expectations return to main's. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- docs/guides/error-handling.md | 12 +- packages/db/mangle-cache.json | 4 - packages/db/src/query/effect.ts | 44 +--- packages/db/src/query/live/ARCHITECTURE.md | 8 +- packages/db/tests/effect.test.ts | 22 +- .../tests/query/subset-error-matrix.test.ts | 34 +-- .../db/tests/source-health-oracle.test.ts | 200 ++---------------- 8 files changed, 45 insertions(+), 281 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 950864d97e..7895d51fe1 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads and overlapping Effect join loads (a request error clears only when a load started after the failure succeeds; 16 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. An Effect lane checks that releasing a failed lazy demand clears its request error, and a joined-filter lane checks that a failed join load lets an ordered window move again. Open: the Effect keeps lazy-demand request errors per source, not per demand, so a demand that only shrinks clears its error by the load-order rule, not by which keys failed; on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. Open: on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index bd5ca979c3..d598402aa8 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -155,8 +155,8 @@ try { } ``` -After an Effect's initial load, a subset failure is a request error on its -`errors` tree, reported through `onErrorsChange`; the Effect keeps running. +Effects report subset failures through `onSourceError` and dispose because +their incremental result can no longer be kept complete. When a source change invalidates an ordered window, automatic full-source repair keeps the last complete snapshot visible. A failed repair exposes @@ -429,10 +429,10 @@ empty restore keeps it `loading`, and a failed restore crashes it. See ### Effects -An Effect ends when a source it reads fails its first load or crashes; it calls -`onSourceError(error)`. After its initial load, any other problem calls -`onErrorsChange(errors)` with the same tree a live query returns, and the -Effect keeps running. +An Effect ends when a source it reads fails its first load or crashes, or when +a load it needs fails; it calls `onSourceError(error)`. After its initial +load, a sync problem on a source calls `onErrorsChange(errors)` with the same +tree a live query returns, and the Effect keeps running. ```ts createEffect({ diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index f11b54c61b..b7c7e84293 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -398,11 +398,8 @@ "includeSourceIds": "gL", "latchReady": "gO", "reportedTree": "gQ", - "settleLoad": "gR", "endedProblem": "gS", "errorTree": "gT", - "failLoad": "gU", - "settleWhenLoaded": "gV", "assertAdapterActive": "bI", "statusError": "cI", "stoppedError": "eN", @@ -427,7 +424,6 @@ "graphHasProcessedInput": "dk", "windowIsInsideOwnPrefix": "gE", "failureReasonFromReload": "g5", - "loadOrders": "g8", "endReload": "g9", "loadOrder": "ha", "reloadTruncateTransaction": "gY", diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 1e50f99a26..1aa8396524 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -6,7 +6,6 @@ import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' -import { LoadOrder, RequestErrors } from '../collection/lifecycle.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -481,8 +480,6 @@ class EffectPipelineRunner { // initial load has settled, with or without skipInitial. After it, // a sync problem does not dispose the Effect. private reachedReady = false - private readonly requests = new RequestErrors() - private readonly loadOrders = new Map() private reportedTree: ErrorTreeNode | undefined private readonly onErrorsChange: ((errors: ErrorTreeNode) => void) | undefined // This Effect as its sources' `subscribers` list it. @@ -650,7 +647,7 @@ class EffectPipelineRunner { ), owner: this.dependent, onLoadSubsetError: ({ error }) => - this.failLoad(sourceId, normalizeError(error)), + this.onSourceError(normalizeError(error)), // subscribeChanges can report a result before it returns the // subscription, so read it from the record when a load fails. onLoadSubsetResult: (result) => { @@ -658,7 +655,6 @@ class EffectPipelineRunner { result, () => this.subscriptions[sourceId]?.pendingTruncateReplacement, ) - this.settleWhenLoaded(sourceId, result) }, }) @@ -698,7 +694,7 @@ class EffectPipelineRunner { const lazyCallbacks = this.lazySourcesCallbacks[sourceId] if (lazyCallbacks) { lazyCallbacks.setDemand = (plan: LazyDemandPlan, keys: Set) => - this.setDemand(sourceId, subscription, plan, keys) + this.setDemand(subscription, plan, keys) for (const plan of lazyCallbacks.plans ?? []) { if (plan.initialKeys.size > 0) { lazyCallbacks.setDemand(plan, plan.initialKeys) @@ -718,7 +714,6 @@ class EffectPipelineRunner { result, () => subscription.pendingTruncateReplacement, ) - this.settleWhenLoaded(sourceId, result) const holdsInitialPublication = this.skipInitial && !this.initialLoadComplete if (holdPublication || holdsInitialPublication) { @@ -876,14 +871,11 @@ class EffectPipelineRunner { } private setDemand( - sourceId: string, subscription: CollectionSubscription, plan: LazyDemandPlan, keys: Set, ): void { let update - // A release can fail during this update; only an earlier failure clears. - const problemBefore = this.requests.problem try { update = this.demand.setDemand(subscription, plan, keys) } catch (error) { @@ -894,14 +886,6 @@ class EffectPipelineRunner { if (this.starting) throw error return } - // A released demand needs none of its rows, so its earlier failure no - // longer stands, as for a live query's retired demand. - if ( - update.changed && - update.empty && - this.requests.problem === problemBefore - ) - this.settleLoad(sourceId) if (update.ready instanceof Promise) { this.trackInitialLoad( update.ready, @@ -910,7 +894,6 @@ class EffectPipelineRunner { // Each segment reports its own failure through the subscription. Consume // the aggregate rejection so Promise.all does not create a second, // detached error channel. - this.settleWhenLoaded(sourceId, update.ready) void update.ready.then( () => { if (!this.disposed) this.scheduleGraphRun() @@ -1116,27 +1099,6 @@ class EffectPipelineRunner { return this.reachedReady } - // Before the gate, a failed load ends the Effect. After it, the failure is - // a request error on the Effect's own node, as on a live query's, until a - // later load of that source settles. - private failLoad(sourceId: string, error: Error): void { - if (!this.reachedReady) return this.onSourceError(error) - this.requests.fail(sourceId, error) - this.reportErrors() - } - - /** A load that later succeeds clears the source's request error. */ - private settleWhenLoaded(sourceId: string, result: unknown): void { - let order = this.loadOrders.get(sourceId) - if (!order) this.loadOrders.set(sourceId, (order = new LoadOrder())) - order.track(result, () => this.settleLoad(sourceId)) - } - - private settleLoad(sourceId: string): void { - if (this.disposed) return - if (this.requests.settle(sourceId)) this.reportErrors() - } - /** The error tree for this effect's sources, in query order. */ private errorTree(): ErrorTreeNode { return errorTreeNode(this, undefined, { @@ -1145,7 +1107,7 @@ class EffectPipelineRunner { kind: `live-query`, status: this.reachedReady ? `ready` : `loading`, failureReason: null, - error: this.requests.problem, + error: null, reloading: false, sources: this.collectionSources.map(({ sourceId, alias, collection }) => buildErrorTree(collection, { alias, id: sourceId }, this), diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 50e23e92c7..478e79c54b 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1701,9 +1701,11 @@ every row before the top-K (a recorded limit). violations, such as a graph failure or a Collection cleaned up under the query, crash the query. An Effect follows the same rules, with its initial-query readiness at its settled initial loads: a crashed source, - or a source whose first load fails before that readiness, ends it through - `onSourceError`; after it, every other problem is error tree detail - delivered through `onErrorsChange`, while the Effect keeps running. + a source whose first load fails before that readiness, or a failed load + it needs ends it through `onSourceError`, because its incremental result + can no longer be complete. An Effect has no request errors; after that + readiness, a source's sync problem is error tree detail delivered through + `onErrorsChange`, while the Effect keeps running. 17. **Error tree:** a Collection's `errors` is one node; a live query's has one node for itself and one per source alias in query order, recursing through nested live queries. A node shows at most one problem: diff --git a/packages/db/tests/effect.test.ts b/packages/db/tests/effect.test.ts index 041b8b6f64..eeb8782c52 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -8,7 +8,6 @@ import { } from './utils.js' import type { DeltaEvent, - ErrorTreeNode, SubscriptionLoadSubsetErrorEvent, } from '../src/index.js' @@ -1969,7 +1968,6 @@ describe(`createEffect`, () => { }, }) const sourceErrors: Array = [] - const trees: Array = [] const effect = createEffect({ query: (q) => q @@ -1983,7 +1981,6 @@ describe(`createEffect`, () => { })), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onErrorsChange: (errors) => trees.push(errors), }) try { @@ -1997,13 +1994,8 @@ describe(`createEffect`, () => { } expect(commitError).toBeUndefined() - // After the Effect's gate, a failed load is a request error on its - // tree; the Effect keeps running. - expect(sourceErrors).toEqual([]) - expect(effect.disposed).toBe(false) - expect(trees.at(-1)?.error).toMatchObject({ - message: failure.message, - }) + expect(sourceErrors).toEqual([failure]) + expect(effect.disposed).toBe(true) } finally { await effect.dispose() await Promise.all([users.cleanup(), issues.cleanup()]) @@ -2047,7 +2039,6 @@ describe(`createEffect`, () => { }, }) const sourceErrors: Array = [] - const trees: Array = [] const effect = createEffect({ query: (q) => q @@ -2061,7 +2052,6 @@ describe(`createEffect`, () => { })), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onErrorsChange: (errors) => trees.push(errors), }) try { @@ -2075,12 +2065,8 @@ describe(`createEffect`, () => { }).not.toThrow() await flushPromises() - // A failed release after the gate is a request error. - expect(sourceErrors).toEqual([]) - expect(effect.disposed).toBe(false) - expect(trees.at(-1)?.error).toMatchObject({ - message: failure.message, - }) + expect(sourceErrors).toEqual([failure]) + expect(effect.disposed).toBe(true) expect(unloadCount).toBe(1) await effect.dispose() diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index c411365213..a4c2d65e05 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -4,7 +4,6 @@ import { BTreeIndex } from '../../src/indexes/btree-index.js' import { createEffect, createLiveQueryCollection, eq } from '../../src/index.js' import { getLoadSubsetDemandKey } from '../../src/query/ir-stable-identity.js' import { mockSyncCollectionOptions } from '../utils.js' -import type { ErrorTreeNode } from '../../src/types.js' type Delivery = `throw` | `reject` type Consumer = `effect` | `live` @@ -128,12 +127,10 @@ function startEffect( primary: RowCollection, child: RowCollection, sourceErrors: Array, - trees: Array = [], ) { const callbacks = { onBatch: () => {}, onSourceError: (error: Error) => sourceErrors.push(error), - onErrorsChange: (errors: ErrorTreeNode) => trees.push(errors), } if (path === `ordered`) { return createEffect({ @@ -333,8 +330,7 @@ describe(`loadSubset failure matrix`, () => { try { if (consumer === `effect`) { const sourceErrors: Array = [] - const trees: Array = [] - const effect = startEffect(path, primary, child, sourceErrors, trees) + const effect = startEffect(path, primary, child, sourceErrors) try { await flushFailures() expect(sourceErrors).toEqual([]) @@ -342,16 +338,13 @@ describe(`loadSubset failure matrix`, () => { expect(() => triggerFailure()).not.toThrow() await flushFailures() - // After the Effect's gate, a failed load is a request error on - // its tree, as on a live query's node; the Effect keeps running. - expect(sourceErrors).toEqual([]) - expect(effect.disposed).toBe(false) - expect(trees.at(-1)?.failureReason).toBeNull() - expect(trees.at(-1)?.error).toMatchObject({ - ...(failureValue === `error` - ? { message: (error as Error).message } - : {}), - }) + expect(sourceErrors).toHaveLength(1) + if (failureValue === `error`) { + expect(sourceErrors[0]).toBe(error) + } else { + expect(sourceErrors[0]).toBeInstanceOf(Error) + } + expect(effect.disposed).toBe(true) } finally { await effect.dispose() } @@ -439,7 +432,6 @@ describe(`loadSubset failure matrix`, () => { }, }) const sourceErrors: Array = [] - const trees: Array = [] const effect = consumer === `effect` ? createEffect({ @@ -451,7 +443,6 @@ describe(`loadSubset failure matrix`, () => { ), onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onErrorsChange: (errors) => trees.push(errors), }) : undefined const live = @@ -478,12 +469,9 @@ describe(`loadSubset failure matrix`, () => { await flushFailures() if (effect) { - // A failed release after the gate is a request error. - expect(sourceErrors).toEqual([]) - expect(effect.disposed).toBe(false) - expect(trees.at(-1)?.error).toMatchObject({ - message: String(failure), - }) + expect(sourceErrors).toHaveLength(1) + expect(sourceErrors[0]?.message).toBe(String(failure)) + expect(effect.disposed).toBe(true) } if (live) { expect(live.utils.lastSubsetError).toBeInstanceOf(Error) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 2500c586ba..56c7564c10 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -93,17 +93,15 @@ * the current tree. `findProblems` must list, in tree order, exactly the * expected nodes with a problem of their own. Request lanes check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's - * node, and that it clears. Effect lanes check the Effect's gate and its - * request errors after its initial-query readiness. + * node, and that it clears. Effect lanes check the Effect's gate, and that a + * failed load after it still ends the Effect: an Effect has no request errors. * * ## Limits * * Collections are eager, so a live query cannot become ready while a Collection is * still loading. The on-demand case, where a Collection fails its first load * after the live query is ready, is not generated here. Per-row child - * Collections are not in the tree; an include's node shows its source. An Effect's request - * error clears on a later successful load of the same Collection; release of - * only the failed demand is not exercised. Pooled histories do not restart: a pooled view ends for good when + * Collections are not in the tree; an include's node shows its source. Pooled histories do not restart: a pooled view ends for good when * its Collection is cleaned up. The ordering check reads the observed query * only, not its Collections. */ @@ -1801,164 +1799,6 @@ describe(`overlapping root loads`, () => { ) }) -// An Effect's lazy join loads for one source overlap when two parents arrive -// before the first child load settles. The same law and model apply. -describe(`overlapping Effect loads`, () => { - const histories: Array = [] - for (const a of [`ok`, `fail`] as const) - for (const b of [`ok`, `fail`] as const) - for (const first of [0, 1] as const) - histories.push({ outcomes: [a, b], first }) - - it.each(histories)( - `outcomes $outcomes, load $first settles first`, - async (history) => { - const loads: Array>> = [] - let deferLoads = false - const parents = createSource(`B`) - const children = createCollection({ - id: `health-effect-overlap-${Math.random().toString(36).slice(2)}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - gcTime: 0, - sync: { - sync: (params) => { - params.markReady() - return { - loadSubset: () => { - if (!deferLoads) return Promise.resolve() - const load = createDeferred() - loads.push(load) - return load.promise - }, - } - }, - }, - }) - const trees: Array = [] - parents.collection.startSyncImmediate() - const insertParent = (id: number) => { - parents.params.begin() - parents.params.write({ type: `insert`, value: { id, parentId: id } }) - parents.params.commit() - } - const consoleError = console.error - console.error = () => {} - const effect = createEffect({ - query: (q) => - q - .from({ parent: parents.collection }) - .leftJoin({ child: children }, ({ parent, child }) => - eq(parent.id, child.parentId), - ), - onBatch: () => {}, - onErrorsChange: (errors) => trees.push(errors), - }) - try { - insertParent(10) - parents.params.markReady() - await flushPromises() - deferLoads = true - insertParent(11) - await flushPromises() - insertParent(12) - await flushPromises() - // Premise: both child loads are in flight at once. - expect(loads).toHaveLength(2) - const model = expectedOverlap(history) - const order = history.first === 0 ? [0, 1] : [1, 0] - for (const [step, load] of order.entries()) { - if (history.outcomes[load] === `ok`) loads[load]!.resolve() - else loads[load]!.reject(new Error(`load ${load} failed`)) - await flushPromises() - expect( - trees.at(-1)?.error?.message ?? null, - `after load ${load} settles`, - ).toBe(model[step]) - } - // A load started after the failure clears it when it succeeds. - if (model.at(-1) !== null) { - insertParent(13) - await flushPromises() - expect(loads).toHaveLength(3) - loads[2]!.resolve() - await flushPromises() - expect( - trees.at(-1)?.error ?? null, - `after a later load succeeds`, - ).toBeNull() - } - } finally { - console.error = consoleError - await effect.dispose() - await parents.collection.cleanup() - await children.cleanup() - } - }, - ) -}) - -// As for a live query, an Effect's request error clears when the demand -// that failed is released: its rows are no longer needed. -it(`clears an Effect's request error when the failed demand is released`, async () => { - let failChildren = false - const parents = createSource(`B`) - const children = createCollection({ - id: `health-effect-release-${Math.random().toString(36).slice(2)}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - gcTime: 0, - sync: { - sync: (params) => { - params.markReady() - return { - loadSubset: () => - failChildren - ? Promise.reject(new Error(`child load failed`)) - : Promise.resolve(), - } - }, - }, - }) - const trees: Array = [] - parents.collection.startSyncImmediate() - const writeParent = (id: number, type: `insert` | `delete` = `insert`) => { - parents.params.begin() - parents.params.write({ type, value: { id, parentId: id } }) - parents.params.commit() - } - const consoleError = console.error - console.error = () => {} - const effect = createEffect({ - query: (q) => - q - .from({ parent: parents.collection }) - .leftJoin({ child: children }, ({ parent, child }) => - eq(parent.id, child.parentId), - ), - onBatch: () => {}, - onErrorsChange: (errors) => trees.push(errors), - }) - try { - parents.params.markReady() - await flushPromises() - failChildren = true - writeParent(1) - await flushPromises() - expect(trees.at(-1)?.error?.message).toBe(`child load failed`) - writeParent(1, `delete`) - await flushPromises() - expect(trees.at(-1)?.error ?? null, `after the demand is released`).toBe( - null, - ) - } finally { - console.error = consoleError - await effect.dispose() - await parents.collection.cleanup() - await children.cleanup() - } -}) - // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- @@ -2132,14 +1972,13 @@ describe(`effect errors`, () => { }) /** - * A load that fails after the Effect's gate is a request error, as it is on - * a live query's node: the Effect is not disposed, `onErrorsChange` receives - * a tree whose root node has a `request` error, and a later successful load - * of the same source clears it. The parents lead the Effect, and a lazy join - * loads the children each new parent row reaches. The lane crosses - * `skipInitial` with the parents' source mode. + * An Effect has no request errors: a load that fails after its gate, as + * before it, ends the Effect through `onSourceError`, and no tree it reported + * shows the failure. The parents lead the Effect, and a lazy join loads the + * children each new parent row reaches. The lane crosses `skipInitial` with + * the parents' source mode. */ -describe(`effect request errors`, () => { +describe(`effect load failures`, () => { type RequestHistory = { skipInitial: boolean parentMode: `eager` | `on-demand` @@ -2215,24 +2054,15 @@ describe(`effect request errors`, () => { if (parentMode === `eager`) parentParams.markReady() await flushPromises() - // Model: after initial-query readiness, a failed load is a request error. + // Model: after initial-query readiness, a failed load ends the Effect. outcomes.push(`fail`) writeParent(2) await flushPromises() - expect(effect.disposed).toBe(false) - expect(sourceErrors).toEqual([]) - expect(trees.at(-1)?.error).toMatchObject({ - message: `child load failed`, - }) - expect(trees.at(-1)?.hasErrors).toBe(true) - - // Model: a later successful load of the same source clears it. - outcomes.push(`ok`) - writeParent(3) - await flushPromises() - expect(effect.disposed).toBe(false) - expect(trees.at(-1)?.error).toBeNull() - expect(trees.at(-1)?.hasErrors).toBe(false) + expect(effect.disposed).toBe(true) + expect(sourceErrors.map((error) => error.message)).toEqual([ + `child load failed`, + ]) + expect(trees.every((tree) => tree.error === null)).toBe(true) } finally { console.error = consoleError await effect.dispose() From cb47c504d1e0d12d6933c09a8b3d92a63765d9f8 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 13:34:18 -0600 Subject: [PATCH 074/106] fix(rxdb): fail the sync run when the change stream errors An errored RxDB change stream ends, so later writes never reach the Collection. The adapter now calls fail() with the stream's error: before the first ready the first load fails, and after it the node's error holds the cause until a reload. Co-authored-by: Isaac --- packages/db/src/types.ts | 5 +- packages/rxdb-db-collection/src/rxdb.ts | 46 ++++++++------- .../rxdb-db-collection/tests/rxdb.test.ts | 59 +++++++++++++++++++ 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6b4f378552..3e0f84e6a2 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -472,8 +472,9 @@ export interface SyncConfig< reportError: (error: unknown) => void /** * The sync adapter stopped. Before the first ready the status becomes - * `error` and `preload()` rejects with `error`. After it, the rows stay and - * the node shows `error` until a reload. Throws after an earlier `fail()`. + * `error` and `preload()` rejects with `error`. After it, the rows stay, the + * status stays `ready`, and the node's `error` holds the cause until a + * reload. Throws after an earlier `fail()`. * While the sync adapter retries, call nothing or `reportError()`. */ fail: (error: unknown) => void diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index e27b7e957c..1c48eaa955 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -225,19 +225,23 @@ export function rxdbCollectionOptions( function startOngoingFetch() { // Subscribe early and buffer live changes during initial load and ongoing - sub = rxCollection.$.subscribe((ev) => { - const cur: Row = stripRxdbFields(clone(ev.documentData as Row)) - switch (ev.operation) { - case `INSERT`: - queue({ type: `insert`, value: cur }) - break - case `UPDATE`: - queue({ type: `update`, value: cur }) - break - case `DELETE`: - queue({ type: `delete`, value: cur }) - break - } + sub = rxCollection.$.subscribe({ + next: (ev) => { + const cur: Row = stripRxdbFields(clone(ev.documentData as Row)) + switch (ev.operation) { + case `INSERT`: + queue({ type: `insert`, value: cur }) + break + case `UPDATE`: + queue({ type: `update`, value: cur }) + break + case `DELETE`: + queue({ type: `delete`, value: cur }) + break + } + }, + // An errored stream ends, so later writes would never arrive. + error: stop, }) const subs = getFromMapOrCreate( @@ -272,19 +276,21 @@ export function rxdbCollectionOptions( } } - if (!isCleanedUp()) { - ready = true + if (!isCleanedUp() && !stopped) { markReady() } } - // Readiness of this sync run: a reload runs `sync` again while the + // Whether this sync run stopped: a reload runs `sync` again while the // Collection still shows the previous run's status. - let ready = false - void start().catch((error: unknown) => { + let stopped = false + function stop(error: unknown) { stopOngoingFetch() - if (!ready) fail(error) - }) + if (stopped) return + stopped = true + fail(error) + } + void start().catch(stop) return stopOngoingFetch }, diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index 308d9c8851..d055b17330 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -8,6 +8,7 @@ import { import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode' import { getRxStorageMemory } from 'rxdb/plugins/storage-memory' import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv' +import { Subject } from 'rxjs' import { stripVirtualProps } from '../../db/tests/utils' import { OPEN_RXDB_SUBSCRIPTIONS, rxdbCollectionOptions } from '../src/rxdb' import type { RxCollection } from 'rxdb/plugins/core' @@ -184,6 +185,64 @@ describe(`RxDB Integration`, () => { }, ) + // The change stream is how the Collection learns of later writes. When it + // errors it ends, so the sync adapter has stopped: before the first ready + // the first load fails, and after it the rows stay and the node's `error` + // holds the cause until a reload. + it.each([`before`, `after`] as const)( + `fails when the change stream errors %s the first ready`, + async (when) => { + const db = await getDatababase(getTestData(1)) + const rxCollection: RxCollection = db.test + const changes = new Subject() + const streamError = new Error(`RxDB change stream failed`) + const stream = vi + .spyOn(rxCollection, `$`, `get`) + .mockReturnValue(changes.asObservable() as never) + const releaseQuery = createDeferred() + const storageQuery = rxCollection.storageInstance.query.bind( + rxCollection.storageInstance, + ) + const query = vi + .spyOn(rxCollection.storageInstance, `query`) + .mockImplementationOnce(async (preparedQuery) => { + await releaseQuery.promise + return storageQuery(preparedQuery) + }) + const collection = createCollection( + rxdbCollectionOptions({ + rxCollection, + startSync: true, + syncBatchSize: 10, + }), + ) + try { + if (when === `before`) { + const preload = collection.preload() + changes.error(streamError) + releaseQuery.resolve() + await expect(preload).rejects.toBe(streamError) + expect(collection.status).toBe(`error`) + } else { + releaseQuery.resolve() + await collection.preload() + changes.error(streamError) + expect(collection.status).toBe(`ready`) + expect(collection.size).toBe(1) + expect(collection.errors.error?.message).toBe(streamError.message) + expect(collection.errors.failureReason).toBeNull() + } + expect(OPEN_RXDB_SUBSCRIPTIONS.get(rxCollection)?.size ?? 0).toBe(0) + } finally { + releaseQuery.resolve() + query.mockRestore() + stream.mockRestore() + await collection.cleanup() + await db.remove() + } + }, + ) + it(`marks initial sync ready only after its rows are applied`, async () => { const db = await getDatababase([{ id: `server`, name: `Server` }]) const rxCollection: RxCollection = db.test From f32d0d432c6ab5cd85a6381a437f3deaf47bb612 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 13:47:16 -0600 Subject: [PATCH 075/106] fix(db): replay on-demand demand when a reload's truncate applies inside sync() A reload's first commit is a truncate. An adapter that calls markReady() or commits inside sync() applies it before sync() returns its loadSubset, so the truncate replay saw no loader, reloaded no subset, and every on-demand subscriber lost its rows. The replay now starts its requests after sync() has returned, so it reaches the new run's loader. The source-health oracle gains an on-demand reload lane (new-run first commit inside or after sync(), filtered and unfiltered queries) and a late-first-load lane (an on-demand Collection that fails, stops, or crashes after its query is ready, then the query's reload()). Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/subscription.ts | 6 +- .../db/tests/source-health-oracle.test.ts | 338 +++++++++++++++++- 3 files changed, 339 insertions(+), 7 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 84f5dc2e46..4e623dfb67 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. Open: on-demand sources that fail their own first load after a live query is ready are generated only in the Effect lane; RxDB's `rxCollection.$` error after readiness is unreported; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Open: a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 726fed41d2..8f937b98c7 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -416,9 +416,9 @@ export class CollectionSubscription private handleTruncate() { // Eager demand is satisfied by source readiness, even when the adapter // happens to expose a loader. Only on-demand demand reacquires a subset. - const reloadsSubsets = - this.collection.config.syncMode === `on-demand` && - this.collection._sync.syncLoadSubsetFn !== null + // A reload's truncate can apply inside the new run's `sync()`, before it + // returns its loader; the requests below start after it has. + const reloadsSubsets = this.collection.config.syncMode === `on-demand` // Deferred demand never acquired, so a truncate gives it nothing to // reload; it acquires the replacement rows when acquisition resumes. const demandsToReload = diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 56c7564c10..656ef9af90 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -95,12 +95,15 @@ * load that fails after initial-query readiness is a request error on the query's * node, and that it clears. Effect lanes check the Effect's gate, and that a * failed load after it still ends the Effect: an Effect has no request errors. + * The on-demand reload lane checks that a reload of an on-demand Collection + * keeps a query's rows until the new run reloads its subset. * * ## Limits * - * Collections are eager, so a live query cannot become ready while a Collection is - * still loading. The on-demand case, where a Collection fails its first load - * after the live query is ready, is not generated here. Per-row child + * The generated Collections are eager, so a live query cannot become ready + * while a Collection is still loading. The on-demand case, where a + * Collection fails its first load after the live query is ready, is not + * generated; a focused lane enumerates it. Per-row child * Collections are not in the tree; an include's node shows its source. Pooled histories do not restart: a pooled view ends for good when * its Collection is cleaned up. The ordering check reads the observed query * only, not its Collections. @@ -1799,6 +1802,335 @@ describe(`overlapping root loads`, () => { ) }) +// --------------------------------------------------------------------------- +// On-demand reload lane +// --------------------------------------------------------------------------- + +/** + * A live query over an on-demand Collection holds rows only for the subsets + * it loaded. A reload retires the sync run that loaded them, so the new run + * must load each subset again. The law: the query keeps its rows until the + * new run has reloaded its subset, and then shows the new run's rows. This + * holds whenever the new run first commits. An adapter often calls + * `markReady()` inside `sync()`, before core has its `loadSubset`. + * + * The provider has rows 1 to 4. Their `parentId` names the run that serves + * them (run 1 gives `id`, run 2 gives `id + 10`), so a read shows which run + * served each row. Histories cross the new run's first commit (an empty + * `markReady()` inside `sync()`, a commit of row 5 inside `sync()`, or an + * empty `markReady()` after `sync()` returns) with an unfiltered query and a + * query for row 1. + * + * The model filters the provider's rows for a run through the query's + * predicate. The driver checks two cuts. While the new run's loads are in + * flight, the query still shows run 1's rows. A direct read of the Collection + * shows only what the new run committed itself: an on-demand Collection holds + * only loaded rows, and a reload's truncate replaced them, as any truncate + * does. When the loads settle, the query and the Collection show run 2's + * rows, and the reload resolves. + */ +type OnDemandReloadHistory = { + firstCommit: `empty-inside-sync` | `row-inside-sync` | `empty-after-sync` + query: `all` | `row-1` +} + +function providerRows(run: number): Array { + return [1, 2, 3, 4].map((id) => ({ + id, + parentId: run === 1 ? id : id + 10, + })) +} + +function expectedOnDemandReload( + history: OnDemandReloadHistory, + run: number, +): Array { + const ownCommit = + run === 2 && history.firstCommit === `row-inside-sync` + ? [{ id: 5, parentId: 15 }] + : [] + return [...providerRows(run), ...ownCommit].filter( + (row) => history.query === `all` || row.id === 1, + ) +} + +describe(`on-demand reload`, () => { + const histories: Array = [] + for (const firstCommit of [ + `empty-inside-sync`, + `row-inside-sync`, + `empty-after-sync`, + ] as const) + for (const query of [`all`, `row-1`] as const) + histories.push({ firstCommit, query }) + + it.each(histories)( + `first commit $firstCommit, query $query`, + async (history) => { + let runs = 0 + const runLoads: Array void>> = [[], [], []] + const newRunReady = createDeferred() + const source = createCollection({ + id: `health-on-demand-reload-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + const run = ++runs + if (run === 1 || history.firstCommit === `empty-inside-sync`) { + params.markReady() + } else if (history.firstCommit === `row-inside-sync`) { + params.begin() + params.write({ type: `insert`, value: { id: 5, parentId: 15 } }) + params.commit() + params.markReady() + } else { + void newRunReady.promise.then(() => params.markReady()) + } + return { + loadSubset: () => { + const serve = () => { + params.begin() + for (const row of providerRows(run)) + params.write({ type: `insert`, value: row }) + params.commit() + } + if (run === 1) { + serve() + return true + } + const load = createDeferred() + runLoads[run]!.push(() => { + serve() + load.resolve() + }) + return load.promise + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => { + const rows = q.from({ row: source }) + return history.query === `all` + ? rows + : rows.where(({ row }) => eq(row.id, 1)) + }) + const sorted = (rows: Array) => + [...rows] + .map(({ id, parentId }) => ({ id, parentId })) + .sort((a, b) => a.id - b.id) + try { + await query.preload() + expect(sorted(query.toArray)).toEqual( + expectedOnDemandReload(history, 1), + ) + + let reloaded = false + const reload = source.reload().then(() => { + reloaded = true + }) + newRunReady.resolve() + await flushPromises() + expect( + runLoads[2]!.length, + `the new run reloads the query's subset`, + ).toBeGreaterThan(0) + expect(sorted(query.toArray), `while the loads are in flight`).toEqual( + expectedOnDemandReload(history, 1), + ) + expect( + sorted(source.toArray), + `a direct read while the loads are in flight`, + ).toEqual( + history.firstCommit === `row-inside-sync` + ? [{ id: 5, parentId: 15 }] + : [], + ) + + for (const serve of runLoads[2]!.splice(0)) serve() + await reload + await flushPromises() + expect(reloaded).toBe(true) + expect(sorted(query.toArray), `after the loads settle`).toEqual( + expectedOnDemandReload(history, 2), + ) + expect(query.status).toBe(`ready`) + expect(source.errors.hasErrors).toBe(false) + } finally { + await query.cleanup() + await source.cleanup() + } + }, + ) +}) + +// --------------------------------------------------------------------------- +// On-demand first load after query readiness +// --------------------------------------------------------------------------- + +/** + * A live query over an on-demand Collection becomes ready when its subsets + * settle, so the Collection can still be in its first load when the query is + * ready. The generated Collections are eager and never reach this state. + * The laws are the ones above. After the query has shown data, only a crash + * changes its status. The Collection's own node shows its first-load failure + * (`error` status) or its stop after readiness (`ready` status, with an + * `error`). The query's `reload()` reloads the Collection, and the new run's + * readiness clears the node. A crashed query's `reload()` rejects. + * + * Histories cross the Collection's step (`fail` before its first ready, + * `fail` after a `markReady()`, or `crash`) with a direct query and an + * inner join to an eager Collection. Each history then calls the query's + * `reload()`. The model gives the node's status and error message, the + * query's status, and its row count at each cut. + */ +type LateFirstLoadHistory = { + step: `fail-before-ready` | `fail-after-ready` | `crash` + shape: `direct` | `join` +} + +type LateFirstLoadCut = { + queryStatus: CollectionStatus + rows: number | null + node: { status: CollectionStatus; error: string | null } +} + +function expectedLateFirstLoad(history: LateFirstLoadHistory): { + afterStep: LateFirstLoadCut + afterReload: LateFirstLoadCut | `rejects` +} { + if (history.step === `crash`) + return { + afterStep: { + queryStatus: `crashed`, + rows: null, + node: { status: `crashed`, error: `stopped` }, + }, + afterReload: `rejects`, + } + return { + afterStep: { + queryStatus: `ready`, + rows: 1, + node: { + status: history.step === `fail-before-ready` ? `error` : `ready`, + error: `stopped`, + }, + }, + afterReload: { + queryStatus: `ready`, + rows: 1, + node: { status: `ready`, error: null }, + }, + } +} + +describe(`on-demand first load after query readiness`, () => { + const histories: Array = [] + for (const step of [ + `fail-before-ready`, + `fail-after-ready`, + `crash`, + ] as const) + for (const shape of [`direct`, `join`] as const) + histories.push({ step, shape }) + + it.each(histories)(`step $step, shape $shape`, async (history) => { + let runs = 0 + let firstRun!: SyncParams + const lazy = createCollection({ + id: `health-late-first-load-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + // The first run stays in its first load; a reload's run is ready. + if (runs++ === 0) firstRun = params + else params.markReady() + return { + loadSubset: () => { + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + return true + }, + } + }, + }, + }) + const eager = createCollection({ + id: `health-late-first-load-eager-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + params.markReady() + }, + }, + }) + const query = createLiveQueryCollection((q) => + history.shape === `direct` + ? q.from({ lazy }) + : q + .from({ eager }) + .join({ lazy }, ({ eager, lazy }) => eq(eager.id, lazy.id)), + ) + const lazyNode = () => + query.errors.sources.find((node) => node.alias === `lazy`)! + const cut = (): LateFirstLoadCut => ({ + queryStatus: query.status, + rows: query.status === `ready` ? query.size : null, + node: { + status: lazyNode().status, + error: lazyNode().error?.message ?? null, + }, + }) + const consoleError = console.error + console.error = () => {} + try { + await query.preload() + // Premise: the query is ready while the Collection is still loading. + expect(lazy.status).toBe(`loading`) + expect(query.size).toBe(1) + + const model = expectedLateFirstLoad(history) + const stopped = new Error(`stopped`) + if (history.step === `crash`) + firstRun.collection._lifecycle.crash(stopped) + else { + if (history.step === `fail-after-ready`) firstRun.markReady() + firstRun.fail(stopped) + } + await flushPromises() + expect(cut(), `after the step`).toEqual(model.afterStep) + expect( + findProblems(query.errors).some((node) => node.alias === `lazy`), + ).toBe(true) + + if (model.afterReload === `rejects`) { + await expect(query.reload()).rejects.toBeDefined() + expect(runs, `a crashed query does not reload its sources`).toBe(1) + } else { + await query.reload() + await flushPromises() + expect(cut(), `after the query's reload`).toEqual(model.afterReload) + expect(findProblems(query.errors)).toEqual([]) + } + } finally { + console.error = consoleError + await query.cleanup() + await lazy.cleanup() + await eager.cleanup() + } + }) +}) + // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- From 3849906d2e35766b0d8a1890a9a51c32a6a28e0f Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 13:47:20 -0600 Subject: [PATCH 076/106] test(query-db): report the random campaign's seed when the test times out Co-authored-by: Isaac --- .../refetch-ordered-prefix-oracle.test.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts index f4d4ad4e90..89cbdcec2a 100644 --- a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts @@ -10,7 +10,7 @@ import { not, } from '@tanstack/db' import fc from 'fast-check' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFailed, vi } from 'vitest' import { oraclePropertyOptions, oracleRuns, @@ -1988,18 +1988,28 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => async (campaign) => { let reached = 0 let reachedDuringHold = 0 + const options = + campaign === `fixed` + ? { seed: 20261013, numRuns: oracleRuns(80) } + : { + ...oraclePropertyOptions(80, property), + ...(campaign === `replay` ? { numRuns: 1 } : {}), + } + // fast-check reports its seed only when the property fails. Choose the + // random campaign's seed here, so a test timeout can report it too. + const seed = options.seed ?? Math.floor(Math.random() * 2 ** 31) + onTestFailed(() => { + console.error( + `${property} ${campaign} campaign: rerun with TANSTACK_DB_ORACLE_SEED=${seed}`, + ) + }) await fc.assert( fc.asyncProperty(caseArbitrary, async (testCase) => { const observation = await withDeadline(checkCase(testCase)) if (observation.reachedLaggingRefetchAfterContinuation) reached++ if (observation.reachedLaggingRefetchDuringHold) reachedDuringHold++ }), - campaign === `fixed` - ? { seed: 20261013, numRuns: oracleRuns(80) } - : { - ...oraclePropertyOptions(80, property), - ...(campaign === `replay` ? { numRuns: 1 } : {}), - }, + { ...options, seed }, ) // Positive reach witness: generated histories serve continuation pages // before a refetch, which is the premise of the gap. From 9c1107289fa2f0983eda353f444f2365ea458ac0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:48:58 +0000 Subject: [PATCH 077/106] ci: apply automated fixes --- .../db/src/query/live/collection-config-builder.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index e397ce39b6..d2123ffef2 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -498,7 +498,6 @@ export class CollectionConfigBuilder< })) } - settleDemand(planId: string, generation: number): void { const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation || demand.settled) return @@ -1032,13 +1031,9 @@ export class CollectionConfigBuilder< // and pays for no facade state. const facadeCompilations = this.bucketFacadesCache const bucketFacades = facadeCompilations - ? new BucketFacadeAdapter( - this.id, - facadeCompilations, - (count) => { - syncState.messagesCount += count - }, - ) + ? new BucketFacadeAdapter(this.id, facadeCompilations, (count) => { + syncState.messagesCount += count + }) : undefined if (bucketFacades) { syncState.unsubscribeCallbacks.add(() => bucketFacades.cleanup()) From 84c86d324e69d89c10163f3258282f3f2f5c71b9 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 13:56:01 -0600 Subject: [PATCH 078/106] fix(db): settle reloads by their own run, not the status they started from Review fixes, each with a witness that failed first: - A reload's new run that calls fail() disarms the reload's truncate, so the kept rows stay the baseline a diffing adapter writes against. A query-db direct write after a failed reload no longer empties the Collection and the Query cache. - reload() rejects while adapter cleanup is pending, as a start does. It no longer starts a sync run under a cleanup. - A live query's reload after its own failed first load is in a first load too (lifecycle.inFirstLoad): a root load that fails again fails the reload, and a graph that throws crashes the query. - The persisted wrapper fences reportError() and reportHealthy() from a retired source session, as it fences every other callback. Co-authored-by: Isaac --- .../src/persisted.ts | 6 + .../tests/persisted-oracle.test.ts | 11 ++ packages/db/src/collection/lifecycle.ts | 11 ++ packages/db/src/collection/sync.ts | 10 +- .../query/live/collection-config-builder.ts | 15 +- .../src/query/live/collection-subscriber.ts | 7 +- .../db/tests/source-health-oracle.test.ts | 128 ++++++++++++++++++ .../query-db-collection/tests/query.test.ts | 41 ++++++ 8 files changed, 215 insertions(+), 14 deletions(-) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 1ab608e4ce..d65e6b7f39 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -6332,6 +6332,12 @@ function createWrappedSyncConfig< fail: (error: unknown) => { if (active()) wrappedParams.fail(error) }, + reportError: (error: unknown) => { + if (active()) wrappedParams.reportError(error) + }, + reportHealthy: () => { + if (active()) wrappedParams.reportHealthy() + }, begin: () => { if (active()) wrappedParams.begin() }, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index d24896ff72..954bc71a45 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -23796,6 +23796,17 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { tx.mutations.some(({ key }) => key === `late-old`), ), ).toBe(false) + // The retired session's reports have no authority over the node: + // its error does not show, and its recovery does not clear the live + // session's problem. + oldSource.reportError(new Error(`retired session`)) + await flushAsyncWork() + expect(collection.errors.failureReason).toBeNull() + sourceSessions[1]!.reportError(new Error(`live session`)) + await flushAsyncWork() + oldSource.reportHealthy() + await flushAsyncWork() + expect(collection.errors.failureReason?.message).toBe(`live session`) } finally { releaseOldCallback.resolve() await Promise.allSettled([lateCallback]) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3b58386364..09c54ca753 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -316,6 +316,17 @@ export class CollectionLifecycleManager< return this.adapterStopped } + /** + * Whether the current sync run is in its first load: `loading`, or a reload + * of a failed first load that is not ready yet. A failure now fails that + * first load. + */ + public get inFirstLoad(): boolean { + return ( + this.status === `loading` || (this.reloading && this.status === `error`) + ) + } + /** Whether a reload's new sync run is in flight. */ public get reloading(): boolean { return this.reloadRun !== undefined diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 64f3478e1d..7be26f5608 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -216,6 +216,11 @@ export class CollectionSyncManager< if (status === `crashed`) { return Promise.reject(new CollectionReloadCrashedError(this.id)) } + try { + this.lifecycle.assertCanStartSync() + } catch (error) { + return Promise.reject(error) + } if (this.lifecycle.reloading) return this.lifecycle.beginReload() if (status === `idle` || status === `cleaned-up`) return this.preload() if (this.syncEntryActive) { @@ -378,7 +383,10 @@ export class CollectionSyncManager< this.lifecycle.markReady() }, fail: (error: unknown) => { - if (isCurrentSync()) this.lifecycle.fail(error) + if (!isCurrentSync()) return + this.lifecycle.fail(error) + // The reload ended without a commit, so its rows stay the baseline. + this.state.reloadTruncatePending = false }, reportError: (error: unknown) => { if (isCurrentSync()) this.lifecycle.reportError(error) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index e397ce39b6..ab49464c72 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -498,7 +498,6 @@ export class CollectionConfigBuilder< })) } - settleDemand(planId: string, generation: number): void { const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation || demand.settled) return @@ -599,7 +598,7 @@ export class CollectionConfigBuilder< if ( !holdPublication && !this.activeWindowOperation && - this.liveQueryCollection?.status !== `loading` && + this.liveQueryCollection?._lifecycle.inFirstLoad !== true && this.pendingOrderedLoads.size === 0 ) { return @@ -740,7 +739,7 @@ export class CollectionConfigBuilder< // load that threw, reported its error. if ( isCurrentSyncRun() && - this.liveQueryCollection?.status !== `error` + this.liveQueryCollection?._lifecycle.stoppedThisRun !== true ) { this.crashQuery( devBuild() && process.env.NODE_ENV !== `production` @@ -1032,13 +1031,9 @@ export class CollectionConfigBuilder< // and pays for no facade state. const facadeCompilations = this.bucketFacadesCache const bucketFacades = facadeCompilations - ? new BucketFacadeAdapter( - this.id, - facadeCompilations, - (count) => { - syncState.messagesCount += count - }, - ) + ? new BucketFacadeAdapter(this.id, facadeCompilations, (count) => { + syncState.messagesCount += count + }) : undefined if (bucketFacades) { syncState.unsubscribeCallbacks.add(() => bucketFacades.cleanup()) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 7cf155a249..2305d9ae17 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -122,8 +122,8 @@ export class CollectionSubscriber< // Lazy demand owns its fatal-error path. For eager sources, one // successful page does not finish initial ordered refinement. !this.collectionConfigBuilder.isLazySource(this.sourceId) && - this.collectionConfigBuilder.liveQueryCollection?.status === - `loading`, + this.collectionConfigBuilder.liveQueryCollection?._lifecycle + .inFirstLoad === true, ) if (!this.collectionConfigBuilder.isLazySource(this.sourceId)) { this.collectionConfigBuilder.failRootLoad(this.sourceId, error) @@ -208,7 +208,8 @@ export class CollectionSubscriber< // Preserve unrelated graph/programming errors as throws. if (!Object.is(subscription.lastError, error)) throw error const isInitialSync = - this.collectionConfigBuilder.liveQueryCollection?.status === `loading` + this.collectionConfigBuilder.liveQueryCollection?._lifecycle + .inFirstLoad === true const generation = this.collectionConfigBuilder.beginDemand(plan.id) this.collectionConfigBuilder.failDemand(plan.id, generation, error) if (isInitialSync) throw error diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 656ef9af90..cf4ca7e7b6 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1277,6 +1277,55 @@ describe(`reload invariant`, () => { await source.collection.cleanup() }) + // Cleanup start closes restart admission (ARCHITECTURE law 14), and a + // reload starts a sync run. While adapter cleanup is pending, a reload + // rejects as a start would, and no sync run starts under the cleanup. + it(`rejects a reload while adapter cleanup is pending`, async () => { + let runs = 0 + const adapterCleanup = createDeferred() + const collection = createCollection({ + id: `health-reload-during-cleanup-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + runs++ + params.markReady() + return () => adapterCleanup.promise + }, + }, + }) + await collection.preload() + const cleaned = collection.cleanup() + await expect(collection.reload()).rejects.toThrow(/during cleanup/) + expect(runs).toBe(1) + adapterCleanup.resolve() + await cleaned + expect(collection.status).toBe(`cleaned-up`) + expect(runs).toBe(1) + }) + + // A reload whose new run fails keeps the rows, so they stay the baseline + // that a diffing adapter, such as query-db, reads for its next write. + it(`keeps the rows as the baseline after the reload's new run fails`, async () => { + const source = createSource(`A`) + source.collection.startSyncImmediate() + source.params.begin() + source.params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + source.params.commit() + source.params.markReady() + const reload = source.collection.reload().catch(() => undefined) + source.params.fail(new Error(`reload failed`)) + await reload + const baseline = [ + ...source.params.collection._state.acceptedSyncedEntries(), + ] + expect(baseline.map(([key]) => key)).toEqual([1]) + expect([...source.collection.keys()]).toEqual([1]) + expect(source.collection.errors.error?.message).toBe(`reload failed`) + await source.collection.cleanup() + }) + // The oracle's driver writes one row key, so it cannot see rows that a // reload failed to replace. This witness writes a new key in the new run. it(`replaces the rows at the commit after a canceled first commit`, async () => { @@ -1522,6 +1571,85 @@ describe(`a query's own failed first load`, () => { await children.cleanup() } }) + + // The query's own reload starts a new run while the status still shows the + // failed first load. That run is in its first load too, so its outcome + // decides the reload: a load that fails again fails it (status `error`, + // the new cause), and a graph that throws crashes the query. + it.each([`ok`, `load-fails`, `graph-throws`] as const)( + `settles the query's reload with its new run: %s`, + async (outcome) => { + const answers: Array<`fail` | `ok`> = [ + `fail`, + outcome === `load-fails` ? `fail` : `ok`, + ] + const source = createCollection({ + id: `health-own-reload-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => { + const answer = answers.shift() ?? `ok` + if (answer === `fail`) + return Promise.reject( + new Error(`load ${2 - answers.length} failed`), + ) + // The rows arrive after the run's sync entry, so a graph + // that throws on them does so in a later graph run. + return Promise.resolve().then(() => { + params.begin() + params.write({ + type: `insert`, + value: { id: 1, parentId: 1 }, + }) + params.commit() + }) + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q.from({ row: source }).fn.select(({ row }) => { + if (outcome === `graph-throws`) throw new Error(`select threw`) + return row + }), + ) + const consoleError = console.error + console.error = () => {} + try { + await query.preload().catch(() => undefined) + expect(query.status).toBe(`error`) + const settled = await Promise.race([ + query.reload().then( + () => `resolved`, + () => `rejected`, + ), + new Promise((resolve) => setTimeout(() => resolve(`pending`), 200)), + ]) + expect(settled).toBe(outcome === `ok` ? `resolved` : `rejected`) + expect(query.status).toBe( + outcome === `ok` + ? `ready` + : outcome === `load-fails` + ? `error` + : `crashed`, + ) + expect(query.errors.reloading).toBe(false) + if (outcome === `ok`) expect(query.size).toBe(1) + if (outcome === `load-fails`) + expect(query.errors.error?.message).toContain(`load 2 failed`) + } finally { + console.error = consoleError + await query.cleanup() + await source.cleanup() + } + }, + ) }) // An ordered window that filters on a lazily joined source waits for its join diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 3d0f560b4f..24e104bcaa 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11779,6 +11779,47 @@ describe(`reload settles on the new run's first result`, () => { } }, ) + + // A failed reload keeps the rows. A direct write after it writes the + // Query cache from the rows the Collection holds, so the cache keeps row 1. + it(`a direct write after a failed reload keeps the kept rows in the Query cache`, async () => { + const answers: Array<`ok` | `fail`> = [`ok`, `fail`] + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + const queryKey = [`reload-then-write`] + const collection = createCollection( + queryCollectionOptions({ + id: `reload-then-write`, + queryKey, + queryClient: client, + getKey: (row: { id: number }) => row.id, + queryFn: (): Promise> => + answers.shift() === `fail` + ? Promise.reject(new Error(`backend down`)) + : Promise.resolve([{ id: 1 }]), + }), + ) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + await collection.preload() + await collection.reload().catch(() => undefined) + expect(collection.status).toBe(`ready`) + expect(collection.errors.error?.message).toBe(`backend down`) + collection.utils.writeInsert({ id: 3 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(collection.toArray.map((row) => row.id).sort()).toEqual([1, 3]) + expect( + (client.getQueryData(queryKey) as Array<{ id: number }>) + .map((row) => row.id) + .sort(), + ).toEqual([1, 3]) + } finally { + consoleError.mockRestore() + await collection.cleanup() + client.clear() + } + }) }) /** From 60319f9d10cddfae4595a86c8079befed11ed0c3 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 14:02:00 -0600 Subject: [PATCH 079/106] fix(db): end an Effect over a stopped source; clear request errors on reload - An Effect that subscribes to a source already in error or crashed ends through onSourceError. No status change follows, so it reads the status when it subscribes, as a live query does. - A source's truncate replay that loads a failed demand again clears its request error, as any later load of that demand does. - A live query's reload() reloads the query itself when its own node has an error, which includes a failed request for more data. The guide offers reload() for that node, and it did nothing. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 2 +- docs/contributing/glossary.md | 2 +- docs/guides/error-handling.md | 8 +- packages/db/src/collection/index.ts | 3 +- packages/db/src/query/effect.ts | 13 ++- .../query/live/collection-config-builder.ts | 15 +++- .../src/query/live/collection-subscriber.ts | 5 ++ .../query/live/subset-demand-controller.ts | 5 ++ .../db/tests/source-health-oracle.test.ts | 87 +++++++++++++++++++ 9 files changed, 126 insertions(+), 14 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index e29ce46335..3f20306aec 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -17,7 +17,7 @@ Collections now answer two questions separately: can the data be shown (`status` - `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It is permanent for the instance and reaches every live query and Effect above it. `error` now means only "the first load failed". - Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. -- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` stays there until its `reload()`. +- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. - Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index f2bc346cbc..3a941fd35e 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -73,7 +73,7 @@ until cleanup ends the run. | crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It is permanent for the Collection instance. After it, sync adapter calls do nothing. | Loading error, sync error, or cleanup. | | sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | -| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills or the demand retires. | Sync error or request data. | +| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. | Sync error or request data. | | error tree | The `errors` value of a Collection, live query, or Effect: one node for a Collection; for a query, one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `errors:change` announces a change. | An error list. | | error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index d598402aa8..e50dcdddc7 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -367,9 +367,11 @@ sync-side truncate: nothing visible changes until the new run's first commit. `error`, and the `reload()` promise rejects. On a live query, `reload()` reloads each of its sources that has an `error`, -then the query itself if it failed its first load. To reload one source -instead, call `reload()` on that source's Collection; a query in `error` -stays there until its own `reload()`. +then the query itself if its own node has an `error`: its first load failed, +or a request for more data failed. To reload one source instead, call +`reload()` on that source's Collection; a query in `error` stays there until +its own `reload()`. A source's reload also loads the query's data again, so it +clears the query's failed requests when those loads succeed. ```ts // Retry a failed first load every 30 seconds until the backend is back. diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 9daf3f603a..0c37357a05 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -1303,7 +1303,8 @@ export class CollectionImpl< * clears the node's problem; a `status` of `error` becomes `ready`. If the * new run fails first, the rows and status stay and the node shows the new * error. On a live query, it reloads each of its sources that has an - * `error`, and then the query itself if its own first load failed. + * `error`, and then the query itself if its own node has an `error`: a + * failed first load or a failed request for more data. * * Returns the promise of a reload in flight when called again. Rejects on a * `crashed` Collection: a crash is permanent for the instance. diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 1aa8396524..fe465bcfcc 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -41,6 +41,7 @@ import type { import type { ChangeMessage, CollectionDependent, + CollectionStatus, ErrorTreeNode, KeyedStream, StringCollationConfig, @@ -754,11 +755,9 @@ class EffectPipelineRunner { } // Listen for status changes on source collections - const statusUnsubscribe = collection.on(`status:change`, (event) => { + const handleSourceStatus = (status: CollectionStatus) => { if (this.disposed) return - const { status } = event - // A crash ends the effect at any time. A failed first load ends it // before its gate; after the gate it is a sync problem in the tree. if ( @@ -791,8 +790,14 @@ class EffectPipelineRunner { } // errors:change follows every status change and reports the tree. this.latchReady() - }) + } + const statusUnsubscribe = collection.on(`status:change`, ({ status }) => + handleSourceStatus(status), + ) this.unsubscribeCallbacks.add(statusUnsubscribe) + // A source that stopped before this subscription sends no status change. + if (collection.status === `crashed` || collection.status === `error`) + handleSourceStatus(collection.status) this.unsubscribeCallbacks.add( collection.on(`errors:change`, () => this.reportErrors()), ) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ab49464c72..93e8dd1325 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -132,7 +132,7 @@ export class CollectionConfigBuilder< /** * Reload each source with an error anywhere in its tree, then this query if - * its own first load failed. A nested live query passes the reload down the + * its own node shows an error. A nested live query passes the reload down the * same way. A source's reload keeps this query's status until it commits. */ async reload(): Promise { @@ -147,9 +147,10 @@ export class CollectionConfigBuilder< findProblems(collection.errors).some((node) => node.error !== null), ) await Promise.all(failed.map((collection) => collection.reload())) - // The query reloads itself after its sources; a reload of it already in - // flight settles with that run. - if (query?.status === `error` || query?._lifecycle.reloading) + // The query reloads itself after its sources when its own node shows an + // error (a failed first load or a request error); a reload of it already + // in flight settles with that run. + if (query?._lifecycle.problems.error || query?._lifecycle.reloading) await query._sync.reload() } @@ -644,6 +645,12 @@ export class CollectionConfigBuilder< this.clearFailedRequest(`root:${sourceId}`) } + /** A source's truncate replay loaded these demands again. */ + settleReplayedRequests(sourceId: string, planIds: Iterable): void { + this.settleRootLoad(sourceId) + for (const planId of planIds) this.clearFailedRequest(planId) + } + // After the first ready, a failed load is a request error on this query's // node. It clears when every failed request settles later or is released. private reportFailedRequest(key: string, error: Error): boolean { diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 2305d9ae17..8bf55962f0 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -435,6 +435,11 @@ export class CollectionSubscriber< return } this.orderedLoader?.settleFullSourceReplay() + // The replay loaded every demand of this subscription again. + this.collectionConfigBuilder.settleReplayedRequests( + this.sourceId, + this.demand.planIds(), + ) this.collectionConfigBuilder.scheduleGraphRunIfSyncRunCurrent( syncRunGeneration, ) diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index 9d80b9047b..01e38c9d38 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -41,6 +41,11 @@ export class SubsetDemandController { private readonly warnedPlans = new Set() private valueIdentity = createValueIdentity() + /** The plans this controller holds demand for. */ + planIds(): IterableIterator { + return this.states.keys() + } + setDemand( subscription: CollectionSubscription, plan: LazyDemandPlan, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index cf4ca7e7b6..cbd69a02df 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1500,6 +1500,43 @@ describe(`request errors`, () => { expect(query.status).toBe(`ready`) })) + // A later load of the failed demand that fulfills clears its error, also + // when a reload issues it: a source's reload replays the query's demand, + // and the query's own reload loads it in a new run. The guide offers + // `reload()` for a node with an `error`, so the query's reload must reach + // a request error on its own node. + it.each([`source`, `query`] as const)( + `a request error clears when the %s reload loads the demand again`, + (reloaded) => + withHistory( + async ({ + query, + children, + outcomes, + writeParent, + markParentsReady, + }) => { + const preload = query.preload() + writeParent(`insert`, 1) + markParentsReady() + await preload + + outcomes.push(`fail`) + writeParent(`insert`, 2) + await flushPromises() + expect(query.errors.error).toMatchObject({ + message: `child load failed`, + }) + + await (reloaded === `source` ? children : query).reload() + await flushPromises() + expect(query.errors.hasErrors).toBe(false) + expect(query.status).toBe(`ready`) + expect(query.size).toBe(2) + }, + ), + ) + it(`a failed load before initial-query readiness fails the query's sync run`, () => withHistory(async ({ query, outcomes, writeParent, markParentsReady }) => { outcomes.push(`fail`) @@ -2431,6 +2468,56 @@ describe(`effect errors`, () => { }) }) +// The Effect lane above creates the Effect first. A source can already be in +// `error` (its first load failed) or `crashed` when the Effect subscribes. +// No status change follows, so the Effect must read the status when it +// subscribes. Model: the Effect never reaches its gate, so either status ends +// it through `onSourceError` once, and no tree is reported. +describe(`effect over a source that already stopped`, () => { + const histories = ([`error`, `crashed`] as const).flatMap((status) => + [true, false].map((skipInitial) => ({ status, skipInitial })), + ) + + it.each(histories)(`%j`, async ({ status, skipInitial }) => { + const source = createCollection({ + id: `health-effect-stopped-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + startSync: true, + sync: { + sync: (params) => { + if (status === `error`) params.fail(new Error(`first load failed`)) + else params.markReady() + }, + }, + }) + await source.preload().catch(() => undefined) + if (status === `crashed`) source._lifecycle.crash(new Error(`crashed`)) + expect(source.status).toBe(status) + const sourceErrors: Array = [] + const trees: Array = [] + const consoleError = console.error + console.error = () => {} + const effect = createEffect({ + query: (q) => q.from({ row: source }), + skipInitial, + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + onErrorsChange: (errors) => trees.push(errors), + }) + try { + await flushPromises() + expect(effect.disposed).toBe(true) + expect(sourceErrors).toHaveLength(1) + expect(trees).toEqual([]) + } finally { + console.error = consoleError + await effect.dispose() + await source.cleanup() + } + }) +}) + /** * An Effect has no request errors: a load that fails after its gate, as * before it, ends the Effect through `onSourceError`, and no tree it reported From d846b18b0a7f0f939994eaeed31e4d94c10a3e5b Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 14:30:02 -0600 Subject: [PATCH 080/106] fix(db): settle a reload whose ready callback throws; announce cleanup's dropped problems Review fixes, each with a witness that failed first: - A reload whose new run calls markReady() after sync() returns, with a ready callback that throws, resolved never. It now settles once the status is ready, as a start's preload does. - Cleanup start drops the discarded run's problems while the status waits for adapter cleanup. It now announces that with errors:change. A crash's cause stays until the status leaves crashed. - A pooled view whose source alias is `root` rebuilt its tree on every read: the view's node and its source's node shared a cache key. - The include sourceIds field, left from the removed include roll-up, is gone; nothing read it. The mangle cache gains two names from #2208. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 4 +- packages/db/src/collection/lifecycle.ts | 21 ++- packages/db/src/query/compiler/index.ts | 7 - packages/db/src/query/live/ARCHITECTURE.md | 48 +++---- .../src/query/live/materialized-pipeline.ts | 3 - packages/db/src/query/pooled-live-query.ts | 9 +- .../db/tests/source-health-oracle.test.ts | 133 ++++++++++++++++++ 8 files changed, 186 insertions(+), 41 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 4e623dfb67..07799d9cd2 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Open: a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Open: the guide calls a crash permanent for the instance, but `cleanup()` then `preload()` restarts a crashed Collection, and the generated `restart` step and other owners (persisted restore, local storage, functional includes, deferred acquisition, and query-db ownership) depend on that restart; the contract needs a decision before either side changes. A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index b7c7e84293..08092ad4ba 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -430,5 +430,7 @@ "cancelSyncTransaction": "g7", "startedBeforeFailure": "hb", "publishedErrors": "hc", - "views": "he" + "views": "he", + "isDeferredSyncKey": "b6", + "overlayHeldAndActive": "e8" } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 09c54ca753..c38851e3d9 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -366,6 +366,12 @@ export class CollectionLifecycleManager< this.recovering = true try { this.markReady() + } catch (error) { + // A ready callback that throws reaches the adapter's markReady(); the + // reload succeeded once the status is ready. + if ((this.status as CollectionStatus) === `ready`) run.resolve() + else run.reject(error) + throw error } finally { this.recovering = false } @@ -658,6 +664,7 @@ export class CollectionLifecycleManager< if (finished) return finished = true this.cleaningUp = false + this.statusError = null attempt(() => this.setStatus(`cleaned-up`)) // Active collection subscriptions still depend on lifecycle events. @@ -718,13 +725,21 @@ export class CollectionLifecycleManager< CleanupQueue.getInstance().cancel(this) this.hasBeenReady = false - // Problems belong to the discarded sync run. + // Problems belong to the discarded sync run. A crash's cause stays until + // the status leaves `crashed`. + const crashed = this.status === `crashed` + const { failureReason, error } = this.problems + const reloading = this.reloading this.adapterStopped = false - this.abandonReload(new CollectionPreloadAbortedError()) this.failureReason = null this.stoppedError = null this.requestErrors = new RequestErrors() - this.statusError = null + if (!crashed) this.statusError = null + // An abandoned reload announces the tree; otherwise announce dropped + // problems here, since the status changes only when cleanup settles. + this.abandonReload(new CollectionPreloadAbortedError()) + if (!crashed && !reloading && (failureReason || error)) + this.events.emitErrorsChange() // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. this.onFirstReadyCallbacks = [] diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index c713b1488b..7fa3d8deac 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -49,7 +49,6 @@ import { Value as ValClass, collectCollectionSources, collectPropRefs, - collectSourceRefs, getFromSources, getHavingExpression, getWhereExpression, @@ -319,8 +318,6 @@ export interface IncludesCompilationResult { parentProjection?: Array /** How the output layer materializes the child result on the parent row */ materialization: IncludesMaterialization - /** The child query's own source IDs, not those of includes nested in it */ - sourceIds: Array /** Internal field used to unwrap scalar child selects */ scalarField?: string } @@ -929,10 +926,6 @@ function compileQueryInternal( childCompilationResult: childResult, parentProjection: subquery.parentProjection, materialization: subquery.materialization, - sourceIds: collectSourceRefs({ - ...subquery.query, - select: undefined, - }).map((source) => source.sourceId), scalarField: subquery.scalarField, }) } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 478e79c54b..dfdac1e3be 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1755,30 +1755,30 @@ keep the meanings defined there. ## Executable contracts -| Contract | Test suite | -| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| State equivalence, route lifecycle, transition history, and batch partition | `packages/db/tests/query/includes-oracle.property.test.ts` | -| Joined multiplicity, alias identity, and null-key normalization | `packages/db/tests/query/includes-query-shape-oracle.test.ts` | -| Alias reuse, captured bindings, public keys, and `SourceId` preservation | (`packages/db/tests/query/includes-oracle.property.test.ts`, `packages/db/tests/query/includes-alias-shadowing-oracle.test.ts`) | -| Same-scope and union alias rejection | `packages/db/tests/query/validate-aliases.test.ts` | -| Demand, cancellation, progressive timing, and source readiness | `packages/db/tests/query/includes-temporal-oracle.test.ts` | -| Optimistic confirmation, rollback, and later reactivity | `packages/db/tests/query/includes-optimistic-oracle.property.test.ts` | -| Coherent layered publication | `packages/db/tests/query/includes-publication-oracle.test.ts` | -| Collection facades, event coherence, and route activation | `packages/db/tests/query/includes-collection-oracle.property.test.ts` | -| Correlated physical work | `packages/db/tests/query/includes-work-counter-oracle.test.ts` | -| Constructed and retained facades in a nested Collection tree | `packages/db/tests/query/includes-space-oracle.test.ts` | -| Route-context discovery and transport across recursive and join boundaries | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | -| Functional projection input boundaries, timing, and output preservation | `packages/db/tests/query/includes-functional-projection-oracle.test.ts` | -| Functional input rejection and inline alternatives | `packages/db/tests/query/includes-functional-input-boundary.test.ts` | -| Public-container descriptors and reference-key matches across internal query stages | `packages/db/tests/query/public-container-copy.test.ts` | -| Cross-formulation equivalence and reference-sensitive route identity | `packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts` | -| Query-db ownership | `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` | -| Failed replay retention, peer isolation, and explicit consumer-only recovery | `packages/db/tests/query/replay-failure-boundary.test.ts` | -| Replay lease balance, reference-counted peers, and failed-start recovery | `packages/db/tests/replay-adapter-ownership.test.ts` | -| Reachable nested shape | `packages/query-db-collection/tests/includes-work-counter-oracle.test.ts` | -| Cleanup-start invalidation, settlement, and restart admission | `packages/db/tests/collection-cleanup-restart-oracle.test.ts` | -| Pooled live queries match their live-query Collection, including cleanup | `packages/db/tests/query/pooled-live-query-oracle.property.test.ts` | -| Deferred acquisition before a subscriber or preload, readiness, and the synchronous cut | `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` | +| Contract | Test suite | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| State equivalence, route lifecycle, transition history, and batch partition | `packages/db/tests/query/includes-oracle.property.test.ts` | +| Joined multiplicity, alias identity, and null-key normalization | `packages/db/tests/query/includes-query-shape-oracle.test.ts` | +| Alias reuse, captured bindings, public keys, and `SourceId` preservation | (`packages/db/tests/query/includes-oracle.property.test.ts`, `packages/db/tests/query/includes-alias-shadowing-oracle.test.ts`) | +| Same-scope and union alias rejection | `packages/db/tests/query/validate-aliases.test.ts` | +| Demand, cancellation, progressive timing, and source readiness | `packages/db/tests/query/includes-temporal-oracle.test.ts` | +| Optimistic confirmation, rollback, and later reactivity | `packages/db/tests/query/includes-optimistic-oracle.property.test.ts` | +| Coherent layered publication | `packages/db/tests/query/includes-publication-oracle.test.ts` | +| Collection facades, event coherence, and route activation | `packages/db/tests/query/includes-collection-oracle.property.test.ts` | +| Correlated physical work | `packages/db/tests/query/includes-work-counter-oracle.test.ts` | +| Constructed and retained facades in a nested Collection tree | `packages/db/tests/query/includes-space-oracle.test.ts` | +| Route-context discovery and transport across recursive and join boundaries | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | +| Functional projection input boundaries, timing, and output preservation | `packages/db/tests/query/includes-functional-projection-oracle.test.ts` | +| Functional input rejection and inline alternatives | `packages/db/tests/query/includes-functional-input-boundary.test.ts` | +| Public-container descriptors and reference-key matches across internal query stages | `packages/db/tests/query/public-container-copy.test.ts` | +| Cross-formulation equivalence and reference-sensitive route identity | `packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts` | +| Query-db ownership | `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` | +| Failed replay retention, peer isolation, and explicit consumer-only recovery | `packages/db/tests/query/replay-failure-boundary.test.ts` | +| Replay lease balance, reference-counted peers, and failed-start recovery | `packages/db/tests/replay-adapter-ownership.test.ts` | +| Reachable nested shape | `packages/query-db-collection/tests/includes-work-counter-oracle.test.ts` | +| Cleanup-start invalidation, settlement, and restart admission | `packages/db/tests/collection-cleanup-restart-oracle.test.ts` | +| Pooled live queries match their live-query Collection, including cleanup of a mounted view | `packages/db/tests/query/pooled-live-query-oracle.property.test.ts` | +| Deferred acquisition before a subscriber or preload, readiness, and the synchronous cut | `packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` | Each oracle identifies the first divergent checkpoint and compares either the whole result or one exact structural difference. Correlated-materialization diff --git a/packages/db/src/query/live/materialized-pipeline.ts b/packages/db/src/query/live/materialized-pipeline.ts index 569f58a6e9..455fa391eb 100644 --- a/packages/db/src/query/live/materialized-pipeline.ts +++ b/packages/db/src/query/live/materialized-pipeline.ts @@ -55,8 +55,6 @@ export type BucketRow = readonly [ export type BucketFacadeCompilation = { edgeId: string - /** The include's own source IDs, whose error-tree nodes roll up its instances. */ - sourceIds: Array rows: IStreamBuilder<[string, BucketRow]> activeBuckets: IStreamBuilder<[string, true]> hasOrderBy: boolean @@ -167,7 +165,6 @@ function materializeRelation( ) as IStreamBuilder<[string, BucketRow]> facades.push({ edgeId, - sourceIds: include.sourceIds, rows: activeBucketRows, activeBuckets, hasOrderBy: include.hasOrderBy, diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 6e683bb34c..8deca1e695 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -556,7 +556,12 @@ class PooledLiveQuery { /** The same error tree a live-query Collection over this source exposes. */ get errors(): ErrorTreeNode { - const source = buildErrorTree(this.source, { alias: this.alias }, this) + // Cache keys apart from the alias, which a query may name `root`. + const source = buildErrorTree( + this.source, + { alias: this.alias, id: `source` }, + this, + ) // The view shows its source's status error. A terminated partition also // ended this view, for example when its source was cleaned up under it. const error = @@ -570,7 +575,7 @@ class PooledLiveQuery { ), )) : null) - return errorTreeNode(this, undefined, { + return errorTreeNode(this, `view`, { alias: `root`, collectionId: this.source.id, kind: `live-query`, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index cbd69a02df..d87fe728af 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1305,6 +1305,91 @@ describe(`reload invariant`, () => { expect(runs).toBe(1) }) + // Cleanup start drops the discarded run's problems while the status waits + // for adapter cleanup. Every change to the tree is announced, so the drop + // is too: the last errors:change before the status change shows no + // problem. A crash's cause stays until the status leaves `crashed`. + it.each([`report`, `fail`, `crash`] as const)( + `announces the problems that cleanup start drops: %s`, + async (problem) => { + const adapterCleanup = createDeferred() + let params!: SyncParams + const collection = createCollection({ + id: `health-cleanup-drop-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (next) => { + params = next + next.markReady() + return () => adapterCleanup.promise + }, + }, + }) + await collection.preload() + if (problem === `report`) params.reportError(new Error(`stalled`)) + else if (problem === `fail`) params.fail(new Error(`stopped`)) + else collection._lifecycle.crash(new Error(`crashed`)) + const announced: Array = [] + collection.on(`errors:change`, () => announced.push(collection.errors)) + const cleaned = collection.cleanup() + expect(collection.status).toBe(problem === `crash` ? `crashed` : `ready`) + if (problem === `crash`) { + expect(collection.errors.error?.message).toBe(`crashed`) + expect(announced).toEqual([]) + } else { + expect(collection.errors.hasErrors).toBe(false) + expect(announced.at(-1)?.hasErrors).toBe(false) + } + adapterCleanup.resolve() + await cleaned + expect(collection.status).toBe(`cleaned-up`) + }, + ) + + // A ready callback that throws reaches the adapter's markReady(), on a + // start and on a reload alike. The reload itself succeeded once the status + // is ready, so it resolves, as a start's preload does. + it.each([`sync`, `async`] as const)( + `settles a reload whose ready callback throws: %s markReady`, + async (timing) => { + let runs = 0 + const collection = createCollection({ + id: `health-reload-ready-throw-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + if (runs++ === 0) { + params.fail(new Error(`first load failed`)) + return + } + if (timing === `sync`) params.markReady() + else + setTimeout(() => { + expect(() => params.markReady()).toThrow(`callback threw`) + }, 0) + }, + }, + }) + await collection.preload().catch(() => undefined) + collection.onFirstReady(() => { + throw new Error(`callback threw`) + }) + const settled = await Promise.race([ + collection.reload().then( + () => `resolved`, + () => `rejected`, + ), + new Promise((resolve) => setTimeout(() => resolve(`pending`), 200)), + ]) + expect(settled).toBe(`resolved`) + expect(collection.status).toBe(`ready`) + expect(collection.errors.reloading).toBe(false) + await collection.cleanup() + }, + ) + // A reload whose new run fails keeps the rows, so they stay the baseline // that a diffing adapter, such as query-db, reads for its next write. it(`keeps the rows as the baseline after the reload's new run fails`, async () => { @@ -2625,6 +2710,54 @@ describe(`effect load failures`, () => { * per-row child Collection that starts changes nothing in the tree, so it * emits no `errors:change`; otherwise every new parent row would rebuild it. */ +// A pooled view's tree keeps node identity, as every tree does, whatever +// alias its query gives the source, including the alias `root` that the +// view's own node shows. The generated pooled shape uses one alias. +describe(`pooled view node identity`, () => { + it.each([`row`, `root`])(`with source alias %s`, async (alias) => { + let params!: SyncParams + const source = createCollection({ + id: `health-pooled-alias-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (next) => { + params = next + next.begin() + next.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + next.commit() + next.markReady() + }, + }, + }) + const view = createPooledLiveQuery( + new Query() + .from({ [alias]: source }) + .where((refs) => + eq( + (refs as Record)[alias]!.parentId, + 1, + ), + ) as unknown as Parameters[0], + ) as unknown as PooledView + const subscription = view.subscribeChanges(() => {}) + try { + await source.preload() + const first = view.errors + expect(first.sources.map((node) => node.alias)).toEqual([alias]) + expect(view.errors, `a second read gives the same tree`).toBe(first) + params.reportError(new Error(`stalled`)) + const reported = view.errors + expect(reported).not.toBe(first) + expect(view.errors, `the changed tree is stable too`).toBe(reported) + expect(reported.sources[0]!.failureReason?.message).toBe(`stalled`) + } finally { + subscription.unsubscribe() + await source.cleanup({ dependents: true }) + } + }) +}) + describe(`include nodes`, () => { it(`keep their shape, and healthy children change nothing`, async () => { const make = (name: string) => { From 72f6fdb3b83f11a11c6a9ff987395dc9015d3f32 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 14:47:35 -0600 Subject: [PATCH 081/106] docs(db): a crash is not recovered in place; a full cleanup starts fresh The guide called a crash permanent for the instance, but cleanup() then preload() restarts a crashed Collection, and existing laws (transient persisted-restore failures, local storage, deferred acquisition, query-db ownership) depend on that restart. The maintainer chose that contract: nothing recovers a crash in place and reload() rejects; a cascade cleanup discards all state, so a later start is a fresh start. The guide, glossary, types, design record, error text, and changeset now say so. A crash-recovery witness checks it; a reload() that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 2 +- .../error-surface/ErrorSurface4.tla | 2 + .../error-surface/README.md | 4 +- .../error-surface/RULES.md | 6 +- .../error-surface/readout.md | 2 +- .../error-surface/user-docs-draft.md | 4 +- docs/contributing/glossary.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- docs/errors.md | 2 +- docs/guides/error-handling.md | 11 ++-- packages/db/src/collection/index.ts | 3 +- packages/db/src/collection/lifecycle.ts | 7 ++- packages/db/src/errors.ts | 2 +- packages/db/src/query/live/ARCHITECTURE.md | 5 +- packages/db/src/types.ts | 3 +- .../db/tests/fixtures/error-messages.json | 2 +- .../db/tests/source-health-oracle.test.ts | 56 +++++++++++++++++++ 17 files changed, 90 insertions(+), 25 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 3f20306aec..8d569d99ce 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -15,7 +15,7 @@ Collections now answer two questions separately: can the data be shown (`status`), and is it still kept up to date (`errors`). Once a query has shown data, a network or backend failure never takes it off the screen. -- `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It is permanent for the instance and reaches every live query and Effect above it. `error` now means only "the first load failed". +- `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It cannot be recovered in place (`reload()` rejects); `cleanup({ dependents: true })` and a fresh start recover. It reaches every live query and Effect above it. `error` now means only "the first load failed". - Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. - `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla b/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla index acc62b0884..a43cdf6a66 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla +++ b/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla @@ -390,6 +390,8 @@ QueryErrorHasAWayOut == QueryErrorNamesAFailure == (mounted /\ qStatus = "error") => \E s \in Sources : reloading[s] \/ gate[s] = "error" \/ problem[s] = "error" +\* Nothing recovers a crash in place: only cleanup leaves 'crashed', and a later +\* PreloadSource starts from 'cleaned' as a fresh Collection would. CrashPermanentForInstance == [][\A s \in Sources : gate[s] = "crashed" => gate'[s] \in {"crashed", "cleaned"}]_vars ReloadNeverShowsLoading == diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/README.md b/docs/contributing/designs/source-failure-and-health/error-surface/README.md index 8095c49d8d..c6031c116d 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/README.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/README.md @@ -29,8 +29,8 @@ Decisions, in short: `crashed`, and works during `loading` and after `error`. A live query's `reload()` reloads its failed sources. 5. `cleanup()` throws while the Collection has subscribers; `subscribers` lists them and - `cleanup({ dependents: true })` cleans them up first. A crash is permanent for the - instance; recovery is a new Collection. + `cleanup({ dependents: true })` cleans them up first. A crash is not recovered in + place; recovery is a cascade cleanup and a fresh start. 6. Persisted Collections join the same model: a synced disk restore opens the gate, a failed remote waits for the restore, remote problems after that are reported, and a failed restore is a crash. `persistedStatus`, `isPersistedReady`, and `persistedError` diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md b/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md index 639062e05f..494dfdc588 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md @@ -54,8 +54,8 @@ R7 Reload. reload() starts a new sync run like a sync-side truncate. Nothing vis atomically and clears the node's problem (an 'error' status becomes 'ready'). If the new run fails first, the old rows and status stay and the node shows the new `error`. reload() while a reload is in flight returns that reload's promise. reload() on a - 'crashed' Collection throws: a crash is permanent for the instance; recovery is a new - Collection. reload() during 'loading' starts a new run; commits from the old run are + 'crashed' Collection throws: a crash is not recovered in place; recovery is a cascade + cleanup, which discards all state, and a fresh start (of the same object or a new one). reload() during 'loading' starts a new run; commits from the old run are ignored. A failed request is re-issued by the new run. R9 Live-query reload. reload() on a live query in 'error' reloads each of its sources in 'error' or with a node `error`. Its status stays 'error' while any source still has a @@ -76,7 +76,7 @@ I3 Status 'ready' implies rows are shown; no after-ready failure or reload blank I4 From what the app can observe, it can pick one remedy: wait, retry the action, reload, report broken storage. I5 No silent recovery or silent degradation: 'error' becomes 'ready' only through a reload's - commit; 'crashed' never becomes 'ready' on the same instance; a failing remote is never + commit; 'crashed' never becomes 'ready' without a cleanup in between; a failing remote is never shown as healthy; a reload never shows 'loading' in place of 'error'. I6 'crashed' always means broken local storage or an invariant failure. I7 Rows shown never come from a superseded run. diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/readout.md b/docs/contributing/designs/source-failure-and-health/error-surface/readout.md index 9ca509945a..b827a2d180 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/readout.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/readout.md @@ -22,7 +22,7 @@ subscribers, a failed reload becoming a loading error, and a query 'error' that `error` = reload. One error field cannot carry that (control OneErrorField). - A query's 'error' clears without its own reload once its sources are ready again through reloads; `query.reload()` is a convenience that reloads its failed sources. -- A crash is permanent for the instance; recovery is cascade cleanup and a new Collection. +- A crash is not recovered in place; recovery is cascade cleanup and a fresh start. ## What it does not tell us - **F1 (decided: b):** a query in 'error' leaves it for 'loading' once no source has a diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md index 7a48a28312..d396701ca7 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md @@ -212,8 +212,8 @@ A Collection crashes when its local storage can no longer serve reads or accept - an internal invariant failed. The Collection moves to `crashed`, and so does every live query over it. A crash is -permanent for that Collection instance: `reload()` throws. To recover, clean up the -Collection together with everything that reads from it, then create a new one. +not recovered in place: `reload()` throws. To recover, clean up the Collection together +with everything that reads from it, then start it again. ```ts console.log(todos.subscribers) // live queries, Effects, and raw subscriptions diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 3a941fd35e..d73b2ec7ba 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -70,7 +70,7 @@ until cleanup ends the run. | --- | --- | --- | | initial gate | The role of Collection status during a sync run's first load. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `fail()`. After Collection readiness in a sync run, sync problems never change the status. | The error tree or a live query's request errors. | | loading error | The `fail()` that ends a sync run's first load in status `error` (`isLoadingError`). It is final for its sync run: `markReady()` afterward throws, and only a new sync run can reach `ready`. | Sync error or a retry. | -| crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It is permanent for the Collection instance. After it, sync adapter calls do nothing. | Loading error, sync error, or cleanup. | +| crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It cannot be recovered in place: nothing restarts it and `reload()` rejects. Only cleanup leaves it, and cleanup discards all of the Collection's state, so a later start is a fresh start. After a crash, sync adapter calls do nothing. | Loading error, sync error, or cleanup. | | sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | | request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. | Sync error or request data. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 07799d9cd2..e085e0f0df 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Open: the guide calls a crash permanent for the instance, but `cleanup()` then `preload()` restarts a crashed Collection, and the generated `restart` step and other owners (persisted restore, local storage, functional includes, deferred acquisition, and query-db ownership) depend on that restart; the contract needs a decision before either side changes. A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/docs/errors.md b/docs/errors.md index 7c944238da..66c3801d6e 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -124,7 +124,7 @@ Cannot clean up collection "collection-1" while 2 live query or Effect dependent Development builds show the full message. For example: ```text -Cannot reload collection "collection-1" because it crashed. A crash is permanent for the Collection instance; clean it up and create a new one. +Cannot reload collection "collection-1" because it crashed. A crash is not recovered in place; clean it up with { dependents: true } and start it again. ``` diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index e50dcdddc7..38d9cb3225 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -400,14 +400,17 @@ accept writes: - its sync function threw, or an internal invariant failed. The Collection moves to `crashed`, and so does every live query and Effect over -it, even after they were ready. A crash is permanent for that Collection -instance: `reload()` rejects. To recover, clean up the Collection together with -everything that reads from it, then create a new one. +it, even after they were ready. A crash cannot be recovered in place: nothing +restarts it, and `reload()` rejects. To recover, clean up the Collection +together with everything that reads from it. Cleanup discards all of its state, +so the next `preload()` or subscriber starts it fresh, as a new Collection +would. If the cause remains, for example a bug in the sync function, the fresh +start crashes again. ```ts console.log(todos.subscribers) // live queries, Effects, and raw subscriptions await todos.cleanup({ dependents: true }) -const freshTodos = createCollection(todosOptions()) +await todos.preload() // a fresh start; mount the queries again ``` ### Cleaning up a Collection diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 0c37357a05..826585f747 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -1307,7 +1307,8 @@ export class CollectionImpl< * failed first load or a failed request for more data. * * Returns the promise of a reload in flight when called again. Rejects on a - * `crashed` Collection: a crash is permanent for the instance. + * `crashed` Collection: a crash is not recovered in place. Clean the + * Collection up with its dependents to start it fresh. */ public reload(): Promise { const builder = getBuilderFromConfig(this.config) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index c38851e3d9..6418dcb4fb 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -132,7 +132,7 @@ export class CollectionLifecycleManager< // A failed first load is final for its sync run. Only a reload leaves // it for `ready` (see `recovering`). error: [`cleaned-up`, `idle`, `crashed`], - // A crash is permanent for the Collection instance. + // Nothing recovers a crash in place; only cleanup leaves it. crashed: [`cleaned-up`], 'cleaned-up': [`loading`, `error`, `crashed`], } @@ -402,8 +402,9 @@ export class CollectionLifecycleManager< /** * Local storage or an internal invariant broke. The Collection can no - * longer serve reads or accept writes, from any active status. A crash is - * permanent for this Collection instance. + * longer serve reads or accept writes, from any active status. Nothing + * recovers a crash in place: only cleanup, which discards all state, leaves + * it. */ public crash(error?: unknown): void { if (this.status === `crashed`) return diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 3ecf17871d..68516564f8 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -163,7 +163,7 @@ export class CollectionReloadCrashedError extends CollectionStateError { constructor(collectionId: string) { super( devBuild() && process.env.NODE_ENV !== `production` - ? `Cannot reload collection "${collectionId}" because it crashed. A crash is permanent for the Collection instance; clean it up and create a new one.` + ? `Cannot reload collection "${collectionId}" because it crashed. A crash is not recovered in place; clean it up with { dependents: true } and start it again.` : codedMessage(250, { collectionId }), ) } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index dfdac1e3be..26e14970bf 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1683,8 +1683,9 @@ every row before the top-K (a recorded limit). failed first load (`fail()` before Collection readiness) is status `error` and is final for that sync run. A crash (IndexedDB closure, a terminal local persistence failure, a throwing sync function, or an - internal invariant failure) is status `crashed` from any active status and - is permanent for the Collection instance. A live query takes its sources' + internal invariant failure) is status `crashed` from any active status. + Nothing recovers it in place; only cleanup, which discards all state, + leaves it. A live query takes its sources' worst status until its initial-query readiness (`crashed` over `error` over `loading`). After it, only `crashed` propagates: a query cannot keep serving rows from a Collection that cannot serve reads. Every other diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 3e0f84e6a2..37ba111ebf 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -772,7 +772,8 @@ export type CollectionStatus = | `error` /** * Local storage or an internal invariant broke. The Collection cannot serve - * reads or accept writes again; recovery is a new Collection. + * reads or accept writes, and `reload()` rejects. Only cleanup leaves it; a + * later start is a fresh start. */ | `crashed` /** Collection has been cleaned up and resources freed */ diff --git a/packages/db/tests/fixtures/error-messages.json b/packages/db/tests/fixtures/error-messages.json index 8970022ee2..17be83ad3d 100644 --- a/packages/db/tests/fixtures/error-messages.json +++ b/packages/db/tests/fixtures/error-messages.json @@ -23,7 +23,7 @@ "Cannot clean up collection \"collection-1\" while 2 live query or Effect dependent(s) read it. Clean them up first, or call cleanup({ dependents: true }). collection.subscribers lists them." ], "CollectionReloadCrashedError": [ - "Cannot reload collection \"collection-1\" because it crashed. A crash is permanent for the Collection instance; clean it up and create a new one." + "Cannot reload collection \"collection-1\" because it crashed. A crash is not recovered in place; clean it up with { dependents: true } and start it again." ], "CollectionReloadDuringSyncEntryError": [ "Cannot reload collection \"collection-1\" from inside its sync function. Reload after the sync function returns." diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index d87fe728af..589ea30dd6 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1636,6 +1636,62 @@ describe(`request errors`, () => { })) }) +// A crash is not recovered in place: `reload()` rejects and nothing starts a +// new sync run. Only cleanup leaves `crashed`, and cleanup discards all of +// the Collection's state, so a later start is a fresh start, as a new +// Collection over the same source would be. Model: after the cascade +// cleanup and a new preload, the Collection and a query mounted again show +// only the new run's rows and no problem. +describe(`crash recovery`, () => { + it(`recovers a crashed Collection only through a full cleanup`, async () => { + let runs = 0 + const collection = createCollection({ + id: `health-crash-recovery-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + const run = ++runs + params.begin() + params.write({ type: `insert`, value: { id: run, parentId: run } }) + params.commit() + params.markReady() + }, + }, + }) + let query = createLiveQueryCollection((q) => q.from({ row: collection })) + const consoleError = console.error + console.error = () => {} + try { + await query.preload() + collection._lifecycle.crash(new Error(`index broke`)) + expect(query.status).toBe(`crashed`) + + await expect(collection.reload()).rejects.toThrow(/crashed/) + await flushPromises() + expect(collection.status, `nothing recovers it in place`).toBe(`crashed`) + expect(runs).toBe(1) + + await collection.cleanup({ dependents: true }) + await collection.preload() + expect(runs).toBe(2) + expect(collection.status).toBe(`ready`) + expect(collection.toArray.map((row) => row.id)).toEqual([2]) + expect(collection.errors.hasErrors).toBe(false) + + query = createLiveQueryCollection((q) => q.from({ row: collection })) + await query.preload() + expect(query.status).toBe(`ready`) + expect(query.toArray.map((row) => row.id)).toEqual([2]) + expect(query.errors.hasErrors).toBe(false) + } finally { + console.error = consoleError + await query.cleanup() + await collection.cleanup({ dependents: true }) + } + }) +}) + // A query whose first load failed waits for `reload()`: another source // becoming ready does not restart it, and its failed load is not retried. The // main grammar fails only sources, so this lane supplies the query's own From 43b85aaac2a37c2962c2e4aca13bd8cd154377a6 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 15:17:28 -0600 Subject: [PATCH 082/106] fix: settle review findings on reload, Effect start, cleanup, and query-db recovery Each fix has a witness that failed first: - A reload's new run that fails with an open transaction no longer lets that transaction truncate on commit; its writes apply on the kept rows. - An Effect over a source already in error or crashed ends before it subscribes or loads, and leaves no errors:change listener behind. - Cleanup start keeps the cause of an error status, as it does a crash's, until the status changes. It announces only problems it actually drops. - query-db orders a Query's recovery against that Query's own failures, not every Query's. A late recovery behind a persisting write counted as stale when another Query failed meanwhile. - Tests still passed the removed include sourceIds field (CI type check). - Hook and guide text now say a cascade cleanup and a fresh start recover a crash. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- docs/guides/error-handling.md | 4 +- packages/angular-db/src/index.ts | 2 +- packages/db/src/collection/lifecycle.ts | 15 +- packages/db/src/collection/sync.ts | 12 ++ packages/db/src/live-query-observer.ts | 2 +- packages/db/src/query/effect.ts | 28 ++-- packages/db/tests/facade-retention.probe.ts | 1 - .../tests/query/bucket-facade-adapter.test.ts | 7 - ...et-facade-rollback-oracle.property.test.ts | 3 - .../db/tests/source-health-oracle.test.ts | 154 ++++++++++++++---- packages/query-db-collection/src/query.ts | 27 ++- .../query-db-collection/tests/query.test.ts | 72 ++++++++ packages/react-db/src/useLiveInfiniteQuery.ts | 2 +- packages/react-db/src/useLiveQuery.ts | 2 +- packages/solid-db/src/useLiveQuery.ts | 2 +- packages/svelte-db/src/useLiveQuery.svelte.ts | 4 +- packages/vue-db/src/useLiveInfiniteQuery.ts | 4 +- packages/vue-db/src/useLiveQuery.ts | 2 +- 19 files changed, 271 insertions(+), 74 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index e085e0f0df..f07d1a009d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 38d9cb3225..0b5cec010b 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -235,7 +235,7 @@ function TaskList() { | `loading` | `isLoading` | The first data is on its way. | Show a placeholder. | | `ready` | `isReady` | The data can be shown. | Show it. Check `isSyncError` for a banner. | | `error` | `isLoadingError` | The first load failed. There is nothing to show. | Show a failure screen and offer `reload()`. | -| `crashed` | `isCrashed` | Local storage broke. This Collection cannot be used again. | Recreate the Collection (see [When a Collection crashes](#when-a-collection-crashes)). | +| `crashed` | `isCrashed` | Local storage broke. Nothing recovers it in place. | Clean it up with its dependents and start it again (see [When a Collection crashes](#when-a-collection-crashes)). | | `cleaned-up` | `isCleanedUp` | The Collection was cleaned up. | Nothing. | Two rules make `status` predictable: @@ -897,7 +897,7 @@ await collection.cleanup() // Resolves successfully To retry a failed first load or a stopped sync, call `reload()` on the Collection or on the live query that reads it. See [Reloading](#reloading). A crashed Collection cannot reload; clean it up with `{ dependents: true }` and -create a new one. See [When a Collection crashes](#when-a-collection-crashes). +start it again. See [When a Collection crashes](#when-a-collection-crashes). ### Graceful Degradation diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index d325b6dcba..f3bda2cbca 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -50,7 +50,7 @@ export interface InjectLiveQueryFlags { isIdle: Signal /** The first load failed: there is nothing to show. */ isLoadingError: Signal - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: Signal /** A signal indicating whether the collection has been cleaned up */ isCleanedUp: Signal diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 6418dcb4fb..beb72557ca 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -726,20 +726,23 @@ export class CollectionLifecycleManager< CleanupQueue.getInstance().cancel(this) this.hasBeenReady = false - // Problems belong to the discarded sync run. A crash's cause stays until - // the status leaves `crashed`. - const crashed = this.status === `crashed` - const { failureReason, error } = this.problems + // Problems belong to the discarded sync run. The cause of a `crashed` or + // `error` status stays while that status shows; finish() clears it. + const before = this.problems const reloading = this.reloading this.adapterStopped = false this.failureReason = null this.stoppedError = null this.requestErrors = new RequestErrors() - if (!crashed) this.statusError = null // An abandoned reload announces the tree; otherwise announce dropped // problems here, since the status changes only when cleanup settles. this.abandonReload(new CollectionPreloadAbortedError()) - if (!crashed && !reloading && (failureReason || error)) + const after = this.problems + if ( + !reloading && + (before.error !== after.error || + before.failureReason !== after.failureReason) + ) this.events.emitErrorsChange() // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 7be26f5608..0871b31046 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -384,9 +384,21 @@ export class CollectionSyncManager< }, fail: (error: unknown) => { if (!isCurrentSync()) return + const reloading = this.lifecycle.reloading this.lifecycle.fail(error) // The reload ended without a commit, so its rows stay the baseline. this.state.reloadTruncatePending = false + // A transaction it opened no longer replaces them: its writes + // apply on the kept rows. + const open = this.state.pendingSyncedTransactions.find( + (transaction) => + transaction === this.reloadTruncateTransaction && + !transaction.committed, + ) + if (reloading && open) { + open.truncate = false + this.state.refreshPendingSyncedProjection() + } }, reportError: (error: unknown) => { if (isCurrentSync()) this.lifecycle.reportError(error) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 9c628d13dc..12f7b1d1d1 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -47,7 +47,7 @@ export interface LiveQuerySnapshot< isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean isEnabled: boolean diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index fe465bcfcc..d53b0a5719 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -211,6 +211,15 @@ let effectCounter = 0 * await effect.dispose() * ``` */ +/** The error that ends an Effect whose source crashed or failed its first load. */ +function stoppedSourceError(collectionId: string, status: CollectionStatus) { + return new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `Source collection '${collectionId}' entered ${status} state` + : codedMessage(135, { collectionId, status }), + ) +} + export function createEffect< TRow extends object = Record, TKey extends string | number = string | number, @@ -638,6 +647,14 @@ class EffectPipelineRunner { else this.handleSourceChanges(sourceId, changes) } + // A source that stopped before this Effect sends no status change. End + // the Effect before any subscription or load reaches that source. + if (collection.status === `crashed` || collection.status === `error`) { + this.onSourceError(stoppedSourceError(collectionId, collection.status)) + this.starting = false + return + } + // Subscribe to source changes const subscription = collection.subscribeChanges(changeCallback, { ...this.buildSubscriptionOptions( @@ -764,13 +781,7 @@ class EffectPipelineRunner { status === `crashed` || (status === `error` && !this.reachedReady) ) { - this.onSourceError( - new Error( - devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' entered ${status} state` - : codedMessage(135, { collectionId, status }), - ), - ) + this.onSourceError(stoppedSourceError(collectionId, status)) return } @@ -795,9 +806,6 @@ class EffectPipelineRunner { handleSourceStatus(status), ) this.unsubscribeCallbacks.add(statusUnsubscribe) - // A source that stopped before this subscription sends no status change. - if (collection.status === `crashed` || collection.status === `error`) - handleSourceStatus(collection.status) this.unsubscribeCallbacks.add( collection.on(`errors:change`, () => this.reportErrors()), ) diff --git a/packages/db/tests/facade-retention.probe.ts b/packages/db/tests/facade-retention.probe.ts index 15c136952a..68aab37957 100644 --- a/packages/db/tests/facade-retention.probe.ts +++ b/packages/db/tests/facade-retention.probe.ts @@ -27,7 +27,6 @@ function capture( [ { edgeId: `children`, - sourceIds: [], rows, activeBuckets, hasOrderBy: false, diff --git a/packages/db/tests/query/bucket-facade-adapter.test.ts b/packages/db/tests/query/bucket-facade-adapter.test.ts index 297cda9016..f234bebeef 100644 --- a/packages/db/tests/query/bucket-facade-adapter.test.ts +++ b/packages/db/tests/query/bucket-facade-adapter.test.ts @@ -70,7 +70,6 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - sourceIds: [], rows, activeBuckets, hasOrderBy: false, @@ -134,7 +133,6 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - sourceIds: [], rows, activeBuckets, hasOrderBy: ordered, @@ -221,7 +219,6 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - sourceIds: [], rows, activeBuckets, hasOrderBy: true, @@ -275,7 +272,6 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - sourceIds: [], rows, activeBuckets, hasOrderBy: true, @@ -443,14 +439,12 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `first`, - sourceIds: [], rows: firstRows, activeBuckets: firstActiveBuckets, hasOrderBy: false, }, { edgeId: `second`, - sourceIds: [], rows: secondRows, activeBuckets: secondActiveBuckets, hasOrderBy: false, @@ -546,7 +540,6 @@ describe(`BucketFacadeAdapter`, () => { [ { edgeId: `children`, - sourceIds: [], rows, activeBuckets, hasOrderBy: false, diff --git a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts index 2c0e82a2bc..8b9b0a00a3 100644 --- a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts @@ -408,7 +408,6 @@ class Driver { [ { edgeId: `children`, - sourceIds: [], rows: this.rows, activeBuckets: this.activity, hasOrderBy: true, @@ -1428,14 +1427,12 @@ describe(`bucket facade rollback`, () => { [ { edgeId: `children`, - sourceIds: [], rows: childRows, activeBuckets: childActive, hasOrderBy: false, }, { edgeId: `parents`, - sourceIds: [], rows: parentRows, activeBuckets: parentActive, hasOrderBy: false, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 589ea30dd6..7f5e3a7466 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1308,8 +1308,9 @@ describe(`reload invariant`, () => { // Cleanup start drops the discarded run's problems while the status waits // for adapter cleanup. Every change to the tree is announced, so the drop // is too: the last errors:change before the status change shows no - // problem. A crash's cause stays until the status leaves `crashed`. - it.each([`report`, `fail`, `crash`] as const)( + // problem. The cause of a `crashed` or `error` status stays while that + // status shows. + it.each([`report`, `fail`, `crash`, `first-load-error`] as const)( `announces the problems that cleanup start drops: %s`, async (problem) => { const adapterCleanup = createDeferred() @@ -1321,21 +1322,34 @@ describe(`reload invariant`, () => { sync: { sync: (next) => { params = next - next.markReady() + if (problem === `first-load-error`) + next.fail(new Error(`first load failed`)) + else next.markReady() return () => adapterCleanup.promise }, }, }) - await collection.preload() + await collection.preload().catch(() => undefined) if (problem === `report`) params.reportError(new Error(`stalled`)) else if (problem === `fail`) params.fail(new Error(`stopped`)) - else collection._lifecycle.crash(new Error(`crashed`)) + else if (problem === `crash`) + collection._lifecycle.crash(new Error(`crashed`)) const announced: Array = [] collection.on(`errors:change`, () => announced.push(collection.errors)) const cleaned = collection.cleanup() - expect(collection.status).toBe(problem === `crash` ? `crashed` : `ready`) - if (problem === `crash`) { - expect(collection.errors.error?.message).toBe(`crashed`) + // A status's own cause stays while that status shows. + const keepsCause = problem === `crash` || problem === `first-load-error` + expect(collection.status).toBe( + problem === `crash` + ? `crashed` + : problem === `first-load-error` + ? `error` + : `ready`, + ) + if (keepsCause) { + expect(collection.errors.error?.message).toBe( + problem === `crash` ? `crashed` : `first load failed`, + ) expect(announced).toEqual([]) } else { expect(collection.errors.hasErrors).toBe(false) @@ -1344,9 +1358,11 @@ describe(`reload invariant`, () => { adapterCleanup.resolve() await cleaned expect(collection.status).toBe(`cleaned-up`) + expect(collection.errors.hasErrors).toBe(false) }, ) + // A ready callback that throws reaches the adapter's markReady(), on a // start and on a reload alike. The reload itself succeeded once the status // is ready, so it resolves, as a start's preload does. @@ -1392,24 +1408,40 @@ describe(`reload invariant`, () => { // A reload whose new run fails keeps the rows, so they stay the baseline // that a diffing adapter, such as query-db, reads for its next write. - it(`keeps the rows as the baseline after the reload's new run fails`, async () => { - const source = createSource(`A`) - source.collection.startSyncImmediate() - source.params.begin() - source.params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) - source.params.commit() - source.params.markReady() - const reload = source.collection.reload().catch(() => undefined) - source.params.fail(new Error(`reload failed`)) - await reload - const baseline = [ - ...source.params.collection._state.acceptedSyncedEntries(), - ] - expect(baseline.map(([key]) => key)).toEqual([1]) - expect([...source.collection.keys()]).toEqual([1]) - expect(source.collection.errors.error?.message).toBe(`reload failed`) - await source.collection.cleanup() - }) + // A transaction the new run opened before it failed is no longer the + // reload's replacement: when it commits, its writes apply on the kept rows. + it.each([`no open transaction`, `an open transaction`] as const)( + `keeps the rows as the baseline after the reload's new run fails with %s`, + async (open) => { + const source = createSource(`A`) + source.collection.startSyncImmediate() + source.params.begin() + source.params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + source.params.commit() + source.params.markReady() + const reload = source.collection.reload().catch(() => undefined) + if (open === `an open transaction`) { + source.params.begin() + source.params.write({ type: `insert`, value: { id: 2, parentId: 2 } }) + } + source.params.fail(new Error(`reload failed`)) + await reload + const baseline = [ + ...source.params.collection._state.acceptedSyncedEntries(), + ] + expect(baseline.map(([key]) => key)).toEqual( + open === `an open transaction` ? [1, 2] : [1], + ) + expect([...source.collection.keys()]).toEqual([1]) + if (open === `an open transaction`) { + await source.params.commit() + expect([...source.collection.keys()].sort()).toEqual([1, 2]) + } + expect(source.collection.status).toBe(`ready`) + expect(source.collection.errors.error?.message).toBe(`reload failed`) + await source.collection.cleanup() + }, + ) // The oracle's driver writes one row key, so it cannot see rows that a // reload failed to replace. This witness writes a new key in the new run. @@ -1473,7 +1505,8 @@ describe(`reload invariant`, () => { describe(`request errors`, () => { function createRequestHistory() { let parentParams!: SyncParams - const outcomes: Array<`ok` | `fail`> = [] + const outcomes: Array<`ok` | `fail` | `hold`> = [] + const held: Array>> = [] const parents = createCollection({ id: `health-request-parents-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, @@ -1495,6 +1528,11 @@ describe(`request errors`, () => { return { loadSubset: (_options: LoadSubsetOptions) => { const outcome = outcomes.shift() ?? `ok` + if (outcome === `hold`) { + const load = createDeferred() + held.push(load) + return load.promise + } return outcome === `ok` ? Promise.resolve() : Promise.reject(new Error(`child load failed`)) @@ -1516,7 +1554,15 @@ describe(`request errors`, () => { parentParams.commit() } const markParentsReady = () => parentParams.markReady() - return { parents, children, query, outcomes, writeParent, markParentsReady } + return { + parents, + children, + query, + outcomes, + held, + writeParent, + markParentsReady, + } } async function withHistory( @@ -1622,6 +1668,36 @@ describe(`request errors`, () => { ), ) + // A replay clears only what failed before it started (as LoadOrder does + // for root loads): a demand that fails while the replay is in flight keeps + // its error when the replay succeeds. The replay holds that failure with + // its publication, so it shows once the replay settles. + it(`a source reload's replay keeps a request error that failed after it started`, () => + withHistory( + async ({ query, children, outcomes, held, writeParent, markParentsReady }) => { + const preload = query.preload() + writeParent(`insert`, 1) + markParentsReady() + await preload + + outcomes.push(`hold`) + const reload = children.reload() + await flushPromises() + expect(held, `the replay is in flight`).toHaveLength(1) + + outcomes.push(`fail`) + writeParent(`insert`, 2) + await flushPromises() + + held[0]!.resolve() + await reload + await flushPromises() + expect(query.errors.error, `the later failure stays`).toMatchObject({ + message: `child load failed`, + }) + }, + )) + it(`a failed load before initial-query readiness fails the query's sync run`, () => withHistory(async ({ query, outcomes, writeParent, markParentsReady }) => { outcomes.push(`fail`) @@ -2620,21 +2696,38 @@ describe(`effect over a source that already stopped`, () => { ) it.each(histories)(`%j`, async ({ status, skipInitial }) => { + let loads = 0 const source = createCollection({ id: `health-effect-stopped-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, gcTime: 0, startSync: true, + syncMode: `on-demand`, sync: { sync: (params) => { if (status === `error`) params.fail(new Error(`first load failed`)) else params.markReady() + return { + loadSubset: () => { + loads++ + return true + }, + } }, }, }) await source.preload().catch(() => undefined) if (status === `crashed`) source._lifecycle.crash(new Error(`crashed`)) expect(source.status).toBe(status) + const listeners = () => + [ + ...( + source as unknown as { + _events: { listeners: Map> } + } + )._events.listeners.values(), + ].reduce((sum, set) => sum + set.size, 0) + const listenersBefore = listeners() const sourceErrors: Array = [] const trees: Array = [] const consoleError = console.error @@ -2651,6 +2744,11 @@ describe(`effect over a source that already stopped`, () => { expect(effect.disposed).toBe(true) expect(sourceErrors).toHaveLength(1) expect(trees).toEqual([]) + // It ends before any subscription or load reaches the source, and it + // leaves no listener on it. + expect(loads, `loads issued to the stopped source`).toBe(0) + expect(source.subscriberCount).toBe(0) + expect(listeners(), `listeners left on the source`).toBe(listenersBefore) } finally { console.error = consoleError await effect.dispose() diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 2a1c137f3f..a1ca2cfde7 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1275,7 +1275,14 @@ export function queryCollectionOptions( const pendingStartupLoads = new Map>() const retainedQueriesPendingRevalidation = new Set() const observedErrorUpdates = new WeakMap() - let errorRevision = 0 + // Per Query: a result application that started before that Query's + // latest failure does not establish its recovery. + const errorRevisions = new Map() + const errorRevisionOf = (hashedQueryKey: string) => + errorRevisions.get(hashedQueryKey) ?? 0 + const bumpErrorRevision = (hashedQueryKey: string) => { + errorRevisions.set(hashedQueryKey, errorRevisionOf(hashedQueryKey) + 1) + } const failedResultApplications = new Map() // A failed fetch or result application after Collection readiness is a // failureReason, since TanStack Query fetches again. It clears once no @@ -2892,7 +2899,10 @@ export function queryCollectionOptions( if (finish()) { failedResultApplications.delete(hashedQueryKey) // Applying cached data during a fetch does not establish recovery. - if (!isFetching && applicationErrorRevision === errorRevision) { + if ( + !isFetching && + applicationErrorRevision === errorRevisionOf(hashedQueryKey) + ) { reportRecovered(hashedQueryKey) } } @@ -2900,7 +2910,7 @@ export function queryCollectionOptions( (error) => { if (!finish()) return failedResultApplications.set(hashedQueryKey, error) - errorRevision++ + bumpErrorRevision(hashedQueryKey) console.error( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, @@ -2919,7 +2929,7 @@ export function queryCollectionOptions( ) => true | Promise, ): void => { invalidatePendingResultApplication(hashedQueryKey) - const currentErrorRevision = errorRevision + const currentErrorRevision = errorRevisionOf(hashedQueryKey) const controller: ResultApplicationController = new AbortController() let settleRefetchAtFetchBoundary = () => {} const fetchBoundary = new Promise((resolve) => { @@ -2944,7 +2954,10 @@ export function queryCollectionOptions( ) resultApplicationControllers.delete(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) - if (!result.isFetching && currentErrorRevision === errorRevision) { + if ( + !result.isFetching && + currentErrorRevision === errorRevisionOf(hashedQueryKey) + ) { reportRecovered(hashedQueryKey) } } @@ -3262,7 +3275,7 @@ export function queryCollectionOptions( if (observedQuery) { observedErrorUpdates.set(observedQuery, result.errorUpdateCount) } - errorRevision++ + bumpErrorRevision(hashedQueryKey) } console.error( @@ -3492,6 +3505,7 @@ export function queryCollectionOptions( if (query?.state.fetchStatus !== `fetching`) fetchStartGenerations.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) + errorRevisions.delete(hashedQueryKey) queryRefCounts.delete(hashedQueryKey) effectivePersistedGcTimes.delete(hashedQueryKey) } @@ -3564,6 +3578,7 @@ export function queryCollectionOptions( if (observer?.getCurrentQuery().state.fetchStatus !== `fetching`) fetchStartGenerations.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) + errorRevisions.delete(hashedQueryKey) queryRefCounts.set(hashedQueryKey, 0) return } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 24e104bcaa..8b0b65a876 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11894,4 +11894,76 @@ describe(`failureReason follows the still-failing Queries`, () => { } }, ) + + // A recovered Query's result can apply late, behind a persisting write. + // Another Query failing meanwhile does not keep the recovered one listed: + // only a newer failure of the same Query does. + it(`a late recovery still counts when another Query fails meanwhile`, async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + const failing = new Set() + const persisting = createDeferred() + const collection = createCollection( + queryCollectionOptions({ + id: `failing-queries-late-recovery`, + queryKey: [`failing-queries-late-recovery`], + queryClient: client, + getKey: (row: { id: number }) => row.id, + syncMode: `on-demand`, + startSync: true, + queryFn: (ctx): Promise> => { + const limit = ( + ctx.meta?.loadSubsetOptions as { limit?: number } | undefined + )?.limit + return failing.has(limit!) + ? Promise.reject(new Error(`subset ${limit} failed`)) + : Promise.resolve([{ id: limit! }]) + }, + onInsert: () => persisting.promise, + }), + ) + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + const refetch = async (limit: number) => { + const query = client + .getQueryCache() + .findAll() + .find( + (q) => + (q.meta?.loadSubsetOptions as { limit?: number } | undefined) + ?.limit === limit, + )! + await query.fetch().catch(() => undefined) + await flushPromises() + } + const shown = () => collection.errors.failureReason?.message ?? null + try { + await collection._sync.loadSubset({ limit: 1 }) + await collection._sync.loadSubset({ limit: 2 }) + await flushPromises() + failing.add(1) + await refetch(1) + expect(shown()).toBe(`subset 1 failed`) + // Query 1 recovers while a write persists, so its result waits. + const write = collection.insert({ id: 100 }) + failing.delete(1) + await refetch(1) + failing.add(2) + await refetch(2) + persisting.resolve() + await write.isPersisted.promise + await flushPromises() + expect(shown(), `query 2 still fails`).toBe(`subset 2 failed`) + failing.delete(2) + await refetch(2) + expect(shown(), `after both recover`).toBeNull() + } finally { + persisting.resolve() + consoleError.mockRestore() + await collection.cleanup() + client.clear() + } + }) }) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 7a5216d874..a712c7a6f3 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -91,7 +91,7 @@ export type UseLiveInfiniteQueryReturnWithCollection< isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean /** The data is shown, and a sync problem or failed page load is in `errors`. */ diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 0a73917e11..c142a9701e 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -321,7 +321,7 @@ type UseLiveQueryFlags = { isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean /** The data is shown, and a sync problem or failed request is in `errors`. */ diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 0a7948523d..0933d27e09 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -47,7 +47,7 @@ type UseLiveQueryFlags = { isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean /** The data is shown, and a sync problem or failed request is in `errors`. */ diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 76f772facf..d0108d344e 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -67,7 +67,7 @@ export interface UseLiveQueryReturn> { isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean /** The data is shown, and a sync problem or failed request is in `errors`. */ @@ -103,7 +103,7 @@ export interface UseLiveQueryReturnWithCollection< isIdle: boolean /** The first load failed: there is nothing to show. */ isLoadingError: boolean - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean /** The data is shown, and a sync problem or failed request is in `errors`. */ diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 443ac93661..9914c68fbf 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -59,7 +59,7 @@ export interface UseLiveInfiniteQueryReturn< isIdle: ComputedRef /** The first load failed: there is nothing to show. */ isLoadingError: ComputedRef - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: ComputedRef isCleanedUp: ComputedRef /** The data is shown, and a sync problem or failed request is in `errors`. */ @@ -86,7 +86,7 @@ export interface UseLiveInfiniteQueryReturnWithCollection< isIdle: ComputedRef /** The first load failed: there is nothing to show. */ isLoadingError: ComputedRef - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: ComputedRef isCleanedUp: ComputedRef /** The data is shown, and a sync problem or failed request is in `errors`. */ diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 93ad783e2c..52732dd8c1 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -61,7 +61,7 @@ export interface UseLiveQueryFlags { isIdle: ComputedRef /** The first load failed: there is nothing to show. */ isLoadingError: ComputedRef - /** Local storage or an invariant broke; recovery is a new Collection. */ + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: ComputedRef isCleanedUp: ComputedRef /** The data is shown, and a sync problem or failed request is in `errors`. */ From 39e4bdc3a723fc52ccca6622d279575e2f8617fe Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:24:11 +0000 Subject: [PATCH 083/106] ci: apply automated fixes --- packages/db/tests/source-health-oracle.test.ts | 10 ++++++++-- packages/query-db-collection/tests/query.test.ts | 4 +--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 7f5e3a7466..374ac8c914 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1362,7 +1362,6 @@ describe(`reload invariant`, () => { }, ) - // A ready callback that throws reaches the adapter's markReady(), on a // start and on a reload alike. The reload itself succeeded once the status // is ready, so it resolves, as a start's preload does. @@ -1674,7 +1673,14 @@ describe(`request errors`, () => { // its publication, so it shows once the replay settles. it(`a source reload's replay keeps a request error that failed after it started`, () => withHistory( - async ({ query, children, outcomes, held, writeParent, markParentsReady }) => { + async ({ + query, + children, + outcomes, + held, + writeParent, + markParentsReady, + }) => { const preload = query.preload() writeParent(`insert`, 1) markParentsReady() diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 8b0b65a876..966ca56df6 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11923,9 +11923,7 @@ describe(`failureReason follows the still-failing Queries`, () => { onInsert: () => persisting.promise, }), ) - const consoleError = vi - .spyOn(console, `error`) - .mockImplementation(() => {}) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) const refetch = async (limit: number) => { const query = client .getQueryCache() From 8a463529bb691e47da562b859f24db45f8f38208 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 16:02:22 -0600 Subject: [PATCH 084/106] test(db): widen the source-health grammar to rows, open transactions, and an Effect The generated source-health oracle now keeps each Collection's rows and the sync run's open transaction. New steps write a row into the open transaction and commit it, across other steps; the driver writes like a diffing adapter. After every step it compares each Collection's rows, the baseline a diffing adapter reads, the query's rows, and an Effect over A (whether it ended, its source errors, and listeners left on A when it ends as it is created). The wider grammar found two defects: - A live query's own reload could keep a row its failed run produced. The new run's graph inserts and retracts the row in one batch, and the builder wrote that balanced delta as an update because the retired run still had the key. The builder now treats a replacing transaction as having no earlier rows. A named history pins the nested trace. - Three persisted fixtures ran on-demand scoped recovery without a loadSubset, so recovery failed (error on main, crashed here) and the tests passed only because the Collection still accepted writes. The fixtures now serve subsets, and each test asserts the Collection is ready before its post-recovery commit. It also corrected the model: data a crashed Collection's run commits still applies, as the IndexedDB retirement oracle requires; only the status calls stop. The glossary now says so. Mutants that restore last round's reload bugs (a failed reload's open transaction still truncates, the empty baseline stays armed, a canceled reload commit does not re-arm) each fail named histories. Co-authored-by: Isaac --- docs/contributing/glossary.md | 2 +- .../tests/persisted-oracle.test.ts | 14 +- .../sqlite-resume-snapshot-oracle.test.ts | 9 +- .../query/live/collection-config-builder.ts | 6 + .../db/tests/source-health-oracle.test.ts | 447 ++++++++++++++++-- 5 files changed, 424 insertions(+), 54 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index d73b2ec7ba..6b4c406462 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -70,7 +70,7 @@ until cleanup ends the run. | --- | --- | --- | | initial gate | The role of Collection status during a sync run's first load. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `fail()`. After Collection readiness in a sync run, sync problems never change the status. | The error tree or a live query's request errors. | | loading error | The `fail()` that ends a sync run's first load in status `error` (`isLoadingError`). It is final for its sync run: `markReady()` afterward throws, and only a new sync run can reach `ready`. | Sync error or a retry. | -| crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It cannot be recovered in place: nothing restarts it and `reload()` rejects. Only cleanup leaves it, and cleanup discards all of the Collection's state, so a later start is a fresh start. After a crash, sync adapter calls do nothing. | Loading error, sync error, or cleanup. | +| crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It cannot be recovered in place: nothing restarts it and `reload()` rejects. Only cleanup leaves it, and cleanup discards all of the Collection's state, so a later start is a fresh start. After a crash, the sync adapter's status calls (`markReady()`, `fail()`, `reportError()`, `reportHealthy()`) do nothing. Sync data that the run commits still applies, because closure keeps accepted sync obligations such as the confirmation of a write admitted before it. | Loading error, sync error, or cleanup. | | sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | | request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. | Sync error or request data. | diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 954bc71a45..bc7c1fcb3a 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -23570,7 +23570,12 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { params.markReady() // The controlled source has no pending asynchronous callback. // Its old commit is already queued inside the persisted wrapper. - return { restartAfterScopedRecovery: () => {} } + return { + // An on-demand source serves the subsets that recovery + // reacquires in the new generation. + loadSubset: () => true, + restartAfterScopedRecovery: () => {}, + } }, }, persistence: { adapter }, @@ -23612,6 +23617,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { value: { id: `new-source`, title: `New source result` }, }) await whenSyncAccepted(source.commit()) + // Recovery succeeded: the Collection still accepts sync data. + expect(collection.status).toBe(`ready`) expect(collection.get(`new-source`)).toMatchObject({ id: `new-source`, title: `New source result`, @@ -28027,6 +28034,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { params.markReady() // Both old commits have entered the wrapper before rotation. return { + // An on-demand source serves the subsets that recovery + // reacquires in the new generation. + loadSubset: () => true, restartAfterScopedRecovery: (cacheRotated) => cacheRotated.then(() => sourceRestarted.resolve()), } @@ -28082,6 +28092,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), ), ).toEqual([]) + // Recovery succeeded: the Collection still accepts sync data. + expect(collection.status).toBe(`ready`) source.begin() source.write({ type: `insert`, diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts index 7e4891f442..afc6e6e5b6 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts @@ -3529,7 +3529,12 @@ describe(`SQLite resume snapshots`, () => { source = params params.markReady() // This controlled source has no delayed provider callbacks. - return { restartAfterScopedRecovery: () => {} } + return { + // An on-demand source serves the subsets that recovery + // reacquires in the new generation. + loadSubset: () => true, + restartAfterScopedRecovery: () => {}, + } }, }, persistence: { adapter }, @@ -3574,6 +3579,8 @@ describe(`SQLite resume snapshots`, () => { source.begin() source.write({ type: `insert`, value: { id: `fresh` } }) await whenSyncAccepted(source.commit()) + // Recovery succeeded: the Collection still accepts sync data. + expect(collection.status).toBe(`ready`) expect(collection.get(`fresh`)).toMatchObject({ id: `fresh` }) const claims = database .prepare( diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 93e8dd1325..ad67a3c0d6 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1114,8 +1114,14 @@ export class CollectionConfigBuilder< facadePublication?.prepare() if (hasParentChanges) { begin() + // A reload's first transaction replaces every row, so no earlier + // row exists for the batch it holds. + const replaces = + config.collection._state.pendingSyncedTransactions.at(-1) + ?.truncate === true let lookup: ((key: string | number) => boolean) | undefined const hasSyncedKey = (key: string | number) => { + if (replaces) return false lookup ??= config.collection._state.createSyncedKeyLookup() return lookup(key) } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 7f5e3a7466..df03fd298b 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -17,7 +17,9 @@ * status and the rows do not change. A node shows at most one problem, and * nothing clears an `error` within a sync run. Once the adapter called * `fail()`, every later report from it is a contract violation that throws. - * After a crash, adapter calls do nothing: core ended the run. + * After a crash, the adapter's status calls do nothing: core ended the + * run. Sync data the run commits still applies, as closure keeps accepted + * sync obligations. * * A live query exposes both axes for everything it depends on through * `collection.errors`: one node for itself and one per source alias in query @@ -49,15 +51,24 @@ * * The model keeps, for each Collection, its status and its problem after * readiness (none, a failure reason, or an error), with a serial for each - * reported problem. It derives - * each live query's gate from its Collections, using only the rules above, - * and builds the expected tree from those facts. It does not read production - * state. + * reported problem. It also keeps the Collection's rows (key and the tag of + * the write that produced each), the sync run's open transaction, and + * whether a reload's replacement is armed. A reload's first transaction + * replaces every row; until the run begins it, a diffing adapter reads no + * rows to diff against. A reload that fails before its first commit keeps + * the rows, and a transaction it began no longer replaces them. A crash ends + * the status calls, but data the run commits still applies. It derives each + * live query's gate from its Collections, using only the rules above, and + * the Effect's state from A: its gate is A's readiness, and a crash, or a + * failed first load before the gate, ends it. It builds the expected tree + * and rows from those facts. It does not read production state. * * ## The history grammar * - * Steps act on Collection `A` or `B`: `ready` writes the Collection's row and calls - * `markReady()`; `fail` calls `fail()`; `report` calls `reportError()`; + * Steps act on Collection `A` or `B`: `ready` writes row 1 during a first + * load or a reload, commits, and calls `markReady()`; `write` adds row 1, 2, + * or 3 to the run's open transaction, beginning one if none is open; + * `commit` commits it; `fail` calls `fail()`; `report` calls `reportError()`; * `healthy` calls `reportHealthy()`; `crash` crashes the Collection through * its lifecycle, as a crash cause would; `reload` calls `collection.reload()`. * A reload keeps the rows, status, and shown problem, and marks the node @@ -66,9 +77,12 @@ * reload of a crashed Collection rejects. `reload-cleanup-fails` reloads while * the retired run's cleanup rejects: that breaks the adapter contract, so the * Collection crashes and the reload ends with it, unless a reload is already in - * flight and nothing is retired. `cancel` begins, writes, and commits with an - * aborted signal: nothing changes, and a reload's truncate waits for the run's - * next commit. A query whose first load failed stays in `error` until its + * flight and nothing is retired. `cancel` commits the open transaction (or a + * new one) with an aborted signal: its writes are gone, and a reload's + * replacement waits for the run's next transaction. The driver writes like + * a diffing adapter: an insert for a key missing from the baseline and an + * update for a key it has. `effect` disposes the Effect over A and creates a + * new one. A query whose first load failed stays in `error` until its * own reload: `reload-query` calls the observed query's `reload()`, which * reloads every Collection below it that shows an error, through a nested * live query, and then the query itself. `restart` cleans up the @@ -83,8 +97,11 @@ * ## The comparison * * After every step the driver compares, with the model: the query's status, - * its row count while ready, the full projected error tree, and the thrown - * error class. It also checks identity: reading twice gives the same tree, + * each Collection's rows and tags, the baseline a diffing adapter reads, the + * query's row count while ready, whether the Effect ended and how often it + * reported a source error, the full projected error tree, and the thrown + * error class. An Effect that ends as it is created leaves A's listener count + * where it was. It also checks identity: reading twice gives the same tree, * and between steps a node is the same object exactly when its expected * facts, including problem serials, did not change. At every event it checks * that no `errors:change` shows a status that `status:change` has not @@ -130,7 +147,9 @@ import type { SyncConfig, } from '../src/types.js' -type Row = { id: number; parentId: number } +// `tag` names the write that produced a row, so a read shows which write +// (and so which sync run) the row came from. +type Row = { id: number; parentId: number; tag?: number } type SyncParams = Parameters[`sync`]>[0] type SourceName = `A` | `B` type Shape = @@ -148,8 +167,15 @@ type Step = | `cancel` source: SourceName } + // `write` adds a row to the sync run's open transaction, beginning one if + // none is open; `commit` commits it (an empty transaction when none is + // open). The transaction stays open across other steps. + | { kind: `write`; source: SourceName; key: RowKey } + | { kind: `commit`; source: SourceName } | { kind: `restart` } | { kind: `reload-query` } + // `effect` disposes the Effect over A and creates a new one. + | { kind: `effect` } // --------------------------------------------------------------------------- // Independent model @@ -168,10 +194,17 @@ type GateModel = { gate: Gate; cause: string | null; reloading: boolean } // run's error still shows. // `failureFromReload`: the new run reported `failure` itself, so its first // commit keeps it. +// `rows` are the applied synced rows; `open` is the run's open transaction. +// `armed`: a reload started and its run has not begun a transaction yet, so +// the next transaction it begins replaces every row, and until then a diffing +// adapter sees no rows to diff against. type SourceModel = GateModel & { failure: Problem stopped: Problem failureFromReload: boolean + rows: Rows + open: OpenTransaction | null + armed: boolean } // A query's reload() reloads the Collections below it that show an error, // then reloads the query itself once they all settle, unless one failed. @@ -180,12 +213,78 @@ type PendingSelfReload = { waits: Set failed: boolean } +type RowKey = 1 | 2 | 3 +// Synced rows by key, each with the tag of the write that produced it. +type Rows = Map +// A sync transaction the run has begun and not committed. `truncate`: the +// transaction is a reload's first, so its commit replaces every row. +type OpenTransaction = { writes: Rows; truncate: boolean } type Model = { sources: Record query: GateModel inner: GateModel serial: number selfReloads: Array + effect: EffectModel +} + +// An Effect over A. It reaches its gate when A is ready. A crash of A ends +// it at any time, and a failed first load of A ends it before its gate, +// including one that A already showed when the Effect was created. After +// its gate, every other problem of A is error tree detail. `live`: it has +// not ended. +type EffectModel = { live: boolean; gate: boolean } + +function deriveEffect(model: Model): void { + const { effect } = model + const A = model.sources.A + if (!effect.live) return + if (A.gate === `crashed` || (A.gate === `error` && !effect.gate)) + effect.live = false + else if (A.gate === `ready`) effect.gate = true +} + +// The rows a diffing adapter reads to choose between insert and update: the +// applied rows with the open transaction's writes on top, or only the open +// transaction's writes when it replaces every row, or none while a reload's +// replacement has not begun. +function expectedBaseline(source: SourceModel): Rows { + if (source.armed) return new Map() + if (source.open?.truncate) return new Map(source.open.writes) + return new Map([...source.rows, ...(source.open?.writes ?? [])]) +} + +// The run begins a transaction. A reload's run replaces every row with its +// first transaction. +function openTransaction(source: SourceModel): OpenTransaction { + if (!source.open) { + source.open = { writes: new Map(), truncate: source.armed } + source.armed = false + } + return source.open +} + +// A reload completes at its first commit when the Collection is ready, and +// at markReady() when its first load failed: the node drops the retired +// run's problems. +function completeReload(source: SourceModel): void { + source.reloading = false + source.gate = `ready` + source.cause = null + if (!source.failureFromReload) source.failure = null + source.failureFromReload = false + source.stopped = null +} + +// Commit the open transaction: a reload's first one replaces every row. +function commitOpen(source: SourceModel): void { + const { writes, truncate } = openTransaction(source) + source.rows = truncate + ? new Map(writes) + : new Map([...source.rows, ...writes]) + source.open = null + if (truncate && source.reloading && source.gate === `ready`) + completeReload(source) } const shapeSources: Record> = { @@ -205,6 +304,9 @@ function freshModel(): Model { failure: null, stopped: null, failureFromReload: false, + rows: new Map(), + open: null, + armed: false, }) return { sources: { A: source(), B: source() }, @@ -212,6 +314,7 @@ function freshModel(): Model { inner: { gate: `loading`, cause: null, reloading: false }, serial: 0, selfReloads: [], + effect: { live: true, gate: false }, } } @@ -267,6 +370,11 @@ function applyModel( Object.assign(model, freshModel()) return `ok` } + if (step.kind === `effect`) { + model.effect = { live: true, gate: false } + deriveEffect(model) + return `ok` + } if (step.kind === `reload-query`) { // A pooled view has no reload of its own. if (shape === `pooled-nested`) return `ok` @@ -277,7 +385,13 @@ function applyModel( const source = model.sources[name] return source.gate === `error` || source.stopped !== null }) - for (const name of reloaded) model.sources[name].reloading = true + for (const name of reloaded) { + const source = model.sources[name] + if (source.reloading) continue + source.reloading = true + source.armed = true + source.open = null + } if (shape === `nested`) { // The outer query reloads the inner query when A shows an error; the // inner query reloads A, then itself. @@ -305,50 +419,75 @@ function applyModel( } deriveQueries(shape, model) settleSelfReloads(shape, model) + deriveEffect(model) return `ok` } // A step on a source this shape does not read has no effect. if (!shapeSources[shape].includes(step.source)) return `ok` const source = model.sources[step.source] let outcome: `ok` | `throws` | `bad-transition` = `ok` - // After a crash, adapter calls do nothing and a reload rejects. After + // After a crash, status calls do nothing and a reload rejects. After // fail(), adapter calls throw until a reload starts a new sync run. const stopped = !source.reloading && (source.gate === `error` || source.stopped !== null) if (step.kind !== `crash` && source.gate === `crashed`) { + // A crash ends the status calls. Data the run commits still applies: + // closure keeps accepted sync obligations, such as the confirmation of + // a write admitted before it (the IndexedDB retirement oracle owns that + // law). + if (step.kind === `write`) + openTransaction(source).writes.set(step.key, ++model.serial) + if (step.kind === `commit`) commitOpen(source) + if (step.kind === `cancel`) source.open = null deriveQueries(shape, model) + deriveEffect(model) return step.kind === `reload` || step.kind === `reload-cleanup-fails` ? `throws` : outcome } switch (step.kind) { case `ready`: - if (source.reloading) { - // The new run's first commit replaces the rows and clears the - // retired run's problems from the node. - source.reloading = false - source.gate = `ready` - source.cause = null - if (!source.failureFromReload) source.failure = null - source.failureFromReload = false - source.stopped = null - } else if (source.gate === `error`) outcome = `bad-transition` + // During a first load or a reload, the run writes row 1 and commits + // before markReady(), as a first load does. + if (source.gate === `loading` || source.reloading) { + openTransaction(source).writes.set(1, ++model.serial) + commitOpen(source) + } + if (source.reloading) completeReload(source) + else if (source.gate === `error`) outcome = `bad-transition` else if (stopped) outcome = `throws` else source.gate = `ready` break + case `write`: + openTransaction(source).writes.set(step.key, ++model.serial) + break + case `commit`: + commitOpen(source) + break case `reload`: - // Everything visible stays until the new run commits or fails. - source.reloading = true + // Everything visible stays until the new run commits or fails. The + // retired run's open transaction is dropped. + if (!source.reloading) { + source.reloading = true + source.armed = true + source.open = null + } break - case `cancel`: - // A commit canceled by its signal never happened: nothing changes, - // and a reload's truncate waits for the run's next commit. + case `cancel`: { + // A commit canceled by its signal never happened: its writes are gone, + // and a reload's replacement waits for the run's next transaction. + const canceled = openTransaction(source) + if (canceled.truncate && source.reloading) source.armed = true + source.open = null break + } case `reload-cleanup-fails`: // A reload in flight returns its promise and retires nothing. if (source.reloading) break // The retired run's cleanup rejecting breaks the adapter contract: - // the Collection crashes and the reload ends with it. + // the Collection crashes and the reload ends with it. The reload + // already dropped the retired run's open transaction. + source.open = null source.gate = `crashed` source.cause = `${step.source} cleanup failed` source.failure = null @@ -359,7 +498,17 @@ function applyModel( // Before Collection readiness the first load fails; after it, the // rows stay and the node shows the error. if (stopped) outcome = `throws` - else if (source.gate !== `ready`) { + else { + // A reload that fails before its first commit keeps the rows: they + // stay the baseline, and a transaction it began no longer replaces + // them. + if (source.reloading) { + source.armed = false + if (source.open) source.open.truncate = false + } + } + if (outcome === `throws`) break + if (source.gate !== `ready`) { source.gate = `error` source.cause = `${step.source} gave up` source.reloading = false @@ -402,6 +551,7 @@ function applyModel( } deriveQueries(shape, model) settleSelfReloads(shape, model) + deriveEffect(model) return outcome } @@ -742,6 +892,42 @@ function expectIdentity( ) } +// A diffing adapter writes an insert for a key missing from its baseline and +// an update for a key it has, so a wrong baseline throws or loses rows. +function writeDiffing(params: SyncParams, key: number, tag: number): void { + const known = new Map(params.collection._state.acceptedSyncedEntries()) + params.write({ + type: known.has(key) ? `update` : `insert`, + value: { id: key, parentId: key, tag }, + }) +} + +function sortedTags(rows: Rows): Array<[number, number]> { + return [...rows].sort(([a], [b]) => a - b) +} + +function tagsOf(rows: ReadonlyArray): Array<[number, number]> { + return sortedTags(new Map(rows.map((row) => [row.id, row.tag!]))) +} + +// The rows a ready query shows. `join()` is a left join, so a join keeps +// every row of its left side; an include shows its parent rows, and the +// pooled view keeps rows whose parentId is 1. +function expectedQuerySize(shape: Shape, model: Model): number { + const { A, B } = model.sources + switch (shape) { + case `direct`: + case `self-join`: + case `join`: + case `nested`: + return A.rows.size + case `include`: + return B.rows.size + case `pooled-nested`: + return A.rows.has(1) ? 1 : 0 + } +} + async function runHistory( shape: Shape, steps: ReadonlyArray, @@ -768,6 +954,30 @@ async function runHistory( let events: Array = [] let announced: string = query.status let stop = () => {} + // The Effect over A, how often it reported a source error, and A's + // listener count just before it was created. + let effect: ReturnType | undefined + let effectErrors: Array = [] + let listenersBeforeEffect = 0 + const listenersOn = (collection: Collection) => + [ + ...( + collection as unknown as { + _events: { listeners: Map> } + } + )._events.listeners.values(), + ].reduce((sum, set) => sum + set.size, 0) + const mountEffect = async () => { + await effect?.dispose() + listenersBeforeEffect = listenersOn(sources.A.collection) + const errors: Array = [] + effectErrors = errors + effect = createEffect({ + query: (q) => q.from({ row: sources.A.collection }), + onBatch: () => {}, + onSourceError: (error) => errors.push(error), + }) + } // Cleanup without subscribers drops event listeners, so each sync run // registers its own. const begin = () => { @@ -790,6 +1000,7 @@ async function runHistory( stopHealth() } start() + void mountEffect() } const consoleError = console.error console.error = () => {} @@ -804,17 +1015,34 @@ async function runHistory( for (const [index, step] of steps.entries()) { const label = `step ${index} ${JSON.stringify(step)} on ${shape}` const before = expectedTree(shape, model, true) + // The run's state before the step decides what the adapter does, as a + // real adapter decides from its own state. + const pre = + `source` in step + ? { + loading: + model.sources[step.source].gate === `loading` || + model.sources[step.source].reloading, + open: model.sources[step.source].open !== null, + } + : undefined const expected = applyModel(shape, model, step) + // The write tag the model gave this step, if it wrote. + const tag = model.serial events = [] let thrown: unknown try { - if (step.kind === `reload-query`) { + if (step.kind === `effect`) { + await mountEffect() + } else if (step.kind === `reload-query`) { const reload = (query as { reload?: () => Promise }).reload?.() if (query.status === `crashed`) thrown = await reload?.catch((e: unknown) => e) else void reload?.catch(() => undefined) await flushPromises() } else if (step.kind === `restart`) { + // The app ends its readers before it cleans up their Collections. + await effect?.dispose() await query.cleanup() await inner?.cleanup({ dependents: true }) await Promise.all([ @@ -842,24 +1070,27 @@ async function runHistory( } sources[step.source].failNextCleanup(false) } else if (step.kind === `ready`) { - // A reload's new run commits its row, as a first load does. - if ( - params.collection.status === `loading` || - params.collection._lifecycle.reloading - ) { - params.begin() - params.write({ - type: `insert`, - value: { id: 1, parentId: 1 }, - }) + // A first load's or a reload's run commits row 1 before + // markReady(), in its open transaction if it has one. + if (pre!.loading) { + if (!pre!.open) params.begin() + writeDiffing(params, 1, tag) params.commit() } params.markReady() + } else if (step.kind === `write`) { + if (!pre!.open) params.begin() + writeDiffing(params, step.key, tag) + } else if (step.kind === `commit`) { + if (!pre!.open) params.begin() + params.commit() } else if (step.kind === `cancel`) { const canceled = new AbortController() canceled.abort() - params.begin() - params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + if (!pre!.open) { + params.begin() + writeDiffing(params, 1, tag) + } try { params.commit(canceled.signal) } catch { @@ -896,8 +1127,45 @@ async function runHistory( expect(query.status, `${label}: gate`).toBe( model.query.gate as CollectionStatus, ) + // Rows: each Collection shows its applied rows, and a diffing adapter + // reads the baseline. A ready query shows the rows its shape derives + // from them. Nothing reads a crashed Collection's baseline. + for (const name of shapeSources[shape]) { + const source = model.sources[name] + const collection = sources[name].collection + expect(tagsOf(collection.toArray), `${label}: ${name} rows`).toEqual( + sortedTags(source.rows), + ) + if (source.gate === `crashed`) continue + expect( + sortedTags( + new Map( + [...collection._state.acceptedSyncedEntries()].map( + ([key, row]) => [key, row.tag!], + ), + ), + ), + `${label}: ${name} baseline`, + ).toEqual(sortedTags(expectedBaseline(source))) + } if (model.query.gate === `ready`) { - expect(size(), `${label}: rows stay while ready`).toBe(1) + expect(size(), `${label}: query rows`).toBe( + expectedQuerySize(shape, model), + ) + } + // The Effect ends once, through onSourceError. One that ends as it is + // created, over a stopped source, leaves nothing on that source. + expect(effect!.disposed, `${label}: effect ended`).toBe( + !model.effect.live, + ) + expect(effectErrors.length, `${label}: effect source errors`).toBe( + model.effect.live ? 0 : 1, + ) + if (step.kind === `effect` && !model.effect.live) { + expect( + listenersOn(sources.A.collection), + `${label}: listeners left by the ended effect`, + ).toBe(listenersBeforeEffect) } const tree = expectedTree(shape, model) const next = query.errors @@ -943,6 +1211,7 @@ async function runHistory( } finally { console.error = consoleError stop() + await effect?.dispose() await query.cleanup() await inner?.cleanup({ dependents: true }) await Promise.all([ @@ -975,6 +1244,58 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { + "a query's reload replaces the rows its failed run left": [ + on(`ready`, `A`), + on(`fail`, `B`), + { kind: `reload-query` }, + on(`ready`, `B`), + ], + "a reload's first transaction replaces every row": [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `write`, source: `A`, key: 2 }, + { kind: `commit`, source: `A` }, + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 3 }, + { kind: `commit`, source: `A` }, + ], + "a failed reload's open transaction applies on the kept rows": [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `write`, source: `A`, key: 2 }, + { kind: `commit`, source: `A` }, + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 3 }, + on(`fail`, `A`), + { kind: `commit`, source: `A` }, + ], + "a failed reload keeps the rows as the adapter's baseline": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + on(`fail`, `A`), + { kind: `write`, source: `A`, key: 1 }, + { kind: `commit`, source: `A` }, + ], + 'a canceled reload transaction leaves the replacement for the next one': [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `write`, source: `A`, key: 2 }, + { kind: `commit`, source: `A` }, + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 3 }, + on(`cancel`, `A`), + { kind: `write`, source: `A`, key: 2 }, + { kind: `commit`, source: `A` }, + ], + 'a crashed Collection still applies the sync data its run commits': [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `write`, source: `A`, key: 2 }, + on(`crash`, `A`), + { kind: `write`, source: `A`, key: 3 }, + { kind: `commit`, source: `A` }, + ], 'a failure reason clears when the sync adapter reports healthy': [ on(`ready`, `A`), on(`ready`, `B`), @@ -1094,8 +1415,24 @@ describe(`source failure and health oracle`, () => { } const stepArb: fc.Arbitrary = fc.oneof( + { + weight: 2, + arbitrary: fc + .tuple( + fc.constantFrom(`A` as const, `B` as const), + fc.constantFrom(1 as const, 2 as const, 3 as const), + ) + .map(([source, key]): Step => ({ kind: `write`, source, key })), + }, + { + weight: 1, + arbitrary: fc + .constantFrom(`A` as const, `B` as const) + .map((source): Step => ({ kind: `commit`, source })), + }, { weight: 1, arbitrary: fc.constant(restart) }, { weight: 1, arbitrary: fc.constant({ kind: `reload-query` } as const) }, + { weight: 1, arbitrary: fc.constant({ kind: `effect` } as const) }, { weight: 6, arbitrary: fc @@ -1116,10 +1453,12 @@ describe(`source failure and health oracle`, () => { }, ) fcTest.prop( - [fc.constantFrom(...shapes), fc.array(stepArb, { maxLength: 8 })], + [fc.constantFrom(...shapes), fc.array(stepArb, { maxLength: 12 })], oraclePropertyOptions(60, `source-health.history`), - )(`generated histories refine the model`, (shape, steps) => - runHistory(shape, steps), + )( + `generated histories refine the model`, + (shape, steps) => runHistory(shape, steps), + 120_000, ) }) @@ -1362,7 +1701,6 @@ describe(`reload invariant`, () => { }, ) - // A ready callback that throws reaches the adapter's markReady(), on a // start and on a reload alike. The reload itself succeeded once the status // is ready, so it resolves, as a start's preload does. @@ -1674,7 +2012,14 @@ describe(`request errors`, () => { // its publication, so it shows once the replay settles. it(`a source reload's replay keeps a request error that failed after it started`, () => withHistory( - async ({ query, children, outcomes, held, writeParent, markParentsReady }) => { + async ({ + query, + children, + outcomes, + held, + writeParent, + markParentsReady, + }) => { const preload = query.preload() writeParent(`insert`, 1) markParentsReady() From d883f7bbdeb6ee70d1f7b78cce22184c27bd7acd Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 16:25:29 -0600 Subject: [PATCH 085/106] test(db): widen the source-health grammar to steps while cleanup is pending New steps start cleanup({ dependents: true }) while the adapter's cleanup stays pending, and let it settle; every other step can run in between. The model owns cleanup start (rows go, the run's problems drop but a status's cause stays, the retired run's calls do nothing, a reload rejects), the cascade (the query and an Effect over the Collection end, without a source error), and the fresh run a reload starts after cleanup settles. Each history now has a 10-second deadline, so a hang is a counterexample. The wider grammar found one more defect in the reload-ends-early class: a crash during a reload left the replacement armed, so a diffing adapter saw no rows and its update of a kept row became a duplicate insert, and a transaction the reload began still replaced every row. Every way a reload ends before its first commit (fail, crash, cleanup) now goes through one sync method that drops the replacement; the fail-only code is gone. Named histories rebuild last round's cleanup bugs; mutants that restore a reload during pending cleanup, the dropped failed-first-load cause, the armed replacement after a crash, and the Effect that never ends over a stopped source each fail. Co-authored-by: Isaac --- packages/db/src/collection/lifecycle.ts | 2 + packages/db/src/collection/sync.ts | 34 ++- .../db/tests/source-health-oracle.test.ts | 282 ++++++++++++++++-- 3 files changed, 281 insertions(+), 37 deletions(-) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index beb72557ca..46a4772fd2 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -293,6 +293,7 @@ export class CollectionLifecycleManager< this.adapterStopped = true const reload = this.reloadRun this.reloadRun = undefined + if (reload) this.sync.dropReloadReplacement() if (this.status === `ready`) { // The rows stay; a reload that failed shows its new error instead. this.failureReason = null @@ -396,6 +397,7 @@ export class CollectionLifecycleManager< const run = this.reloadRun if (!run) return false this.reloadRun = undefined + this.sync.dropReloadReplacement() run.reject(error) return true } diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 0871b31046..2f533a3963 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -383,22 +383,7 @@ export class CollectionSyncManager< this.lifecycle.markReady() }, fail: (error: unknown) => { - if (!isCurrentSync()) return - const reloading = this.lifecycle.reloading - this.lifecycle.fail(error) - // The reload ended without a commit, so its rows stay the baseline. - this.state.reloadTruncatePending = false - // A transaction it opened no longer replaces them: its writes - // apply on the kept rows. - const open = this.state.pendingSyncedTransactions.find( - (transaction) => - transaction === this.reloadTruncateTransaction && - !transaction.committed, - ) - if (reloading && open) { - open.truncate = false - this.state.refreshPendingSyncedProjection() - } + if (isCurrentSync()) this.lifecycle.fail(error) }, reportError: (error: unknown) => { if (isCurrentSync()) this.lifecycle.reportError(error) @@ -555,6 +540,23 @@ export class CollectionSyncManager< } } + /** + * A reload ended without its first commit, by a failure, a crash, or + * cleanup: its rows stay the baseline, and a transaction it began no longer + * replaces them, so its writes apply on the kept rows. + */ + public dropReloadReplacement(): void { + this.state.reloadTruncatePending = false + const open = this.state.pendingSyncedTransactions.find( + (transaction) => + transaction === this.reloadTruncateTransaction && + !transaction.committed, + ) + if (!open) return + open.truncate = false + this.state.refreshPendingSyncedProjection() + } + /** The reload's first transaction replaces every synced row. */ private markReloadTruncate(applied: { promise: Promise }): void { this.state.reloadTruncatePending = false diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index df03fd298b..74288b20bb 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -82,7 +82,17 @@ * replacement waits for the run's next transaction. The driver writes like * a diffing adapter: an insert for a key missing from the baseline and an * update for a key it has. `effect` disposes the Effect over A and creates a - * new one. A query whose first load failed stays in `error` until its + * new one. `cleanup-hold` starts `cleanup({ dependents: true })` while the + * adapter's cleanup stays pending, and `cleanup-settle` lets it finish. + * Cleanup start ends the sync run at once: the rows go, the node drops the + * run's problems but keeps a failed first load's or a crash's cause while + * the status shows it, the retired run's calls do nothing, and a reload + * rejects. The cascade ends the query and an Effect over the Collection, + * without a source error. After cleanup settles, the status is `cleaned-up`, + * and a reload starts a fresh sync run. Outside the grammar: a crash of a + * Collection whose cleanup started, a query's reload after a cleanup ended + * it, a new Effect while A's cleanup is pending, and cleanup of the pooled + * shape. A history that does not settle within 10 seconds fails. A query whose first load failed stays in `error` until its * own reload: `reload-query` calls the observed query's `reload()`, which * reloads every Collection below it that shows an error, through a nested * live query, and then the query itself. `restart` cleans up the @@ -176,12 +186,15 @@ type Step = | { kind: `reload-query` } // `effect` disposes the Effect over A and creates a new one. | { kind: `effect` } + // `cleanup-hold` starts `cleanup({ dependents: true })` while the adapter's + // cleanup stays pending; `cleanup-settle` lets it finish. + | { kind: `cleanup-hold` | `cleanup-settle`; source: SourceName } // --------------------------------------------------------------------------- // Independent model // --------------------------------------------------------------------------- -type Gate = `loading` | `ready` | `error` | `crashed` +type Gate = `loading` | `ready` | `error` | `crashed` | `cleaned-up` // `serial` names one reported problem. A new report is a new problem even // when it has the same field, so the node that shows it is a new node. type Problem = null | { message: string; serial: number } @@ -198,7 +211,9 @@ type GateModel = { gate: Gate; cause: string | null; reloading: boolean } // `armed`: a reload started and its run has not begun a transaction yet, so // the next transaction it begins replaces every row, and until then a diffing // adapter sees no rows to diff against. +// `cleaning`: cleanup started and the adapter's cleanup has not settled. type SourceModel = GateModel & { + cleaning: boolean failure: Problem stopped: Problem failureFromReload: boolean @@ -226,22 +241,85 @@ type Model = { serial: number selfReloads: Array effect: EffectModel + // A cascade cleanup ended the observed query (and, below it, the inner + // query): it shows `cleaned-up` and announces nothing more. + queryEnded: boolean + innerEnded: boolean } // An Effect over A. It reaches its gate when A is ready. A crash of A ends // it at any time, and a failed first load of A ends it before its gate, // including one that A already showed when the Effect was created. After -// its gate, every other problem of A is error tree detail. `live`: it has -// not ended. -type EffectModel = { live: boolean; gate: boolean } +// its gate, every other problem of A is error tree detail. A cascade cleanup +// of A ends it without a source error: the app asked for it. `live`: it has +// not ended; `errors`: how often it reported a source error. +type EffectModel = { live: boolean; gate: boolean; errors: number } function deriveEffect(model: Model): void { const { effect } = model const A = model.sources.A if (!effect.live) return - if (A.gate === `crashed` || (A.gate === `error` && !effect.gate)) + if (A.gate === `crashed` || (A.gate === `error` && !effect.gate)) { effect.live = false - else if (A.gate === `ready`) effect.gate = true + effect.errors++ + } else if (A.gate === `ready`) effect.gate = true +} + +// A fresh sync run of a cleaned-up Collection: nothing of the old run stays. +function restartSource(source: SourceModel): void { + Object.assign(source, { + gate: `loading`, + cause: null, + reloading: false, + failure: null, + stopped: null, + failureFromReload: false, + cleaning: false, + rows: new Map(), + open: null, + armed: false, + } satisfies SourceModel) +} + +// Cleanup start invalidates the sync run at once: the rows go, the node drops +// the run's problems but keeps a crash's or failed first load's cause while +// the status shows it, and the status waits for the adapter's cleanup. A +// cascade ends every live query and Effect that reads the Collection. A +// second cleanup returns the pending one. +function applyCleanup( + shape: Shape, + model: Model, + kind: `cleanup-hold` | `cleanup-settle`, + name: SourceName, +): void { + const source = model.sources[name] + if (!shapeSources[shape].includes(name)) return + if (kind === `cleanup-settle`) { + if (!source.cleaning) return + source.cleaning = false + source.gate = `cleaned-up` + source.cause = null + deriveQueries(shape, model) + return + } + if (source.cleaning || source.gate === `cleaned-up`) return + Object.assign(source, { + cleaning: true, + reloading: false, + failure: null, + stopped: null, + failureFromReload: false, + rows: new Map(), + open: null, + armed: false, + }) + model.queryEnded = true + model.selfReloads = [] + if (name === `A`) { + model.innerEnded = true + if (model.effect.live) model.effect.live = false + } + deriveQueries(shape, model) } // The rows a diffing adapter reads to choose between insert and update: the @@ -304,6 +382,7 @@ function freshModel(): Model { failure: null, stopped: null, failureFromReload: false, + cleaning: false, rows: new Map(), open: null, armed: false, @@ -314,7 +393,9 @@ function freshModel(): Model { inner: { gate: `loading`, cause: null, reloading: false }, serial: 0, selfReloads: [], - effect: { live: true, gate: false }, + effect: { live: true, gate: false, errors: 0 }, + queryEnded: false, + innerEnded: false, } } @@ -345,6 +426,14 @@ function deriveGate( function deriveQueries(shape: Shape, model: Model): void { const gates = (names: ReadonlyArray) => names.map((name) => model.sources[name]) + const ended: GateModel = { gate: `cleaned-up`, cause: null, reloading: false } + if (model.innerEnded) model.inner = ended + if (model.queryEnded) { + model.query = shape === `pooled-nested` ? model.inner : ended + if (shape === `nested` && !model.innerEnded) + model.inner = deriveGate(model.inner, gates([`A`])) + return + } if (shape === `nested`) { model.inner = deriveGate(model.inner, gates([`A`])) model.query = deriveGate(model.query, [model.inner, model.sources.B]) @@ -371,13 +460,22 @@ function applyModel( return `ok` } if (step.kind === `effect`) { - model.effect = { live: true, gate: false } + // Excluded while A's cleanup is pending. Over a cleaned-up A, the new + // Effect's subscription starts A's next sync run. + if (model.sources.A.cleaning) return `ok` + if (model.sources.A.gate === `cleaned-up`) restartSource(model.sources.A) + model.effect = { live: true, gate: false, errors: 0 } deriveEffect(model) return `ok` } + if (step.kind === `cleanup-hold` || step.kind === `cleanup-settle`) { + applyCleanup(shape, model, step.kind, step.source) + return `ok` + } if (step.kind === `reload-query`) { - // A pooled view has no reload of its own. - if (shape === `pooled-nested`) return `ok` + // A pooled view has no reload of its own. A query a cleanup ended is + // outside the grammar for its reload. + if (shape === `pooled-nested` || model.queryEnded) return `ok` if (model.query.gate === `crashed`) return `throws` // The query reloads every Collection below it that shows an error, // through any nested live query; a reload in flight keeps its run. @@ -430,6 +528,19 @@ function applyModel( // fail(), adapter calls throw until a reload starts a new sync run. const stopped = !source.reloading && (source.gate === `error` || source.stopped !== null) + // While cleanup is pending, and after it, the retired run's calls do + // nothing. A reload is a start: it rejects during cleanup, and after it a + // fresh sync run starts. Core crashes only a running Collection, so a + // crash there is outside the grammar. + if (source.cleaning || source.gate === `cleaned-up`) { + if (step.kind === `reload` || step.kind === `reload-cleanup-fails`) { + if (source.cleaning) return `throws` + restartSource(source) + deriveQueries(shape, model) + deriveEffect(model) + } + return `ok` + } if (step.kind !== `crash` && source.gate === `crashed`) { // A crash ends the status calls. Data the run commits still applies: // closure keeps accepted sync obligations, such as the confirmation of @@ -539,7 +650,12 @@ function applyModel( } break case `crash`: - // From any active status, for good. + // From any active status, for good. A reload ends with it, before its + // first commit: the rows stay the baseline. + if (source.reloading) { + source.armed = false + if (source.open) source.open.truncate = false + } if (source.gate !== `crashed`) { source.gate = `crashed` source.cause = `${step.source} crashed` @@ -729,6 +845,8 @@ function expectedTree( function createSource(name: SourceName) { let params: SyncParams | undefined let failNextCleanup = false + // A held adapter cleanup stays pending until the driver settles it. + let held: ReturnType> | undefined const collection = createCollection({ id: `health-oracle-${name}-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, @@ -738,6 +856,7 @@ function createSource(name: SourceName) { sync: (next) => { params = next return () => { + if (held) return held.promise if (!failNextCleanup) return failNextCleanup = false return Promise.reject(new Error(`${name} cleanup failed`)) @@ -750,6 +869,13 @@ function createSource(name: SourceName) { failNextCleanup: (armed = true) => { failNextCleanup = armed }, + holdCleanup: () => { + held ??= createDeferred() + }, + settleCleanup: () => { + held?.resolve() + held = undefined + }, get params(): SyncParams { if (!params) throw new Error(`source ${name} has not started`) return params @@ -928,6 +1054,24 @@ function expectedQuerySize(shape: Shape, model: Model): number { } } +// A history that hangs fails as a counterexample, not as a test timeout. +async function withDeadline(work: Promise): Promise { + let timer: ReturnType | undefined + try { + await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`history did not settle within 10 s`)), + 10_000, + ) + }), + ]) + } finally { + clearTimeout(timer) + } +} + async function runHistory( shape: Shape, steps: ReadonlyArray, @@ -942,7 +1086,12 @@ async function runHistory( // A pooled view ends for good when its source is cleaned up, and a later // mount gets a new view, so its histories do not restart. if (shape === `pooled-nested`) { - steps = steps.filter((step) => step.kind !== `restart`) + steps = steps.filter( + (step) => + step.kind !== `restart` && + step.kind !== `cleanup-hold` && + step.kind !== `cleanup-settle`, + ) } // Each event, with the status announced so far and the tree read then. type Event = { @@ -956,6 +1105,13 @@ async function runHistory( let stop = () => {} // The Effect over A, how often it reported a source error, and A's // listener count just before it was created. + const pendingCleanups: Partial>> = {} + const settleCleanups = async () => { + for (const name of [`A`, `B`] as const) { + sources[name].settleCleanup() + await pendingCleanups[name] + } + } let effect: ReturnType | undefined let effectErrors: Array = [] let listenersBeforeEffect = 0 @@ -1024,8 +1180,14 @@ async function runHistory( model.sources[step.source].gate === `loading` || model.sources[step.source].reloading, open: model.sources[step.source].open !== null, + retired: + model.sources[step.source].cleaning || + model.sources[step.source].gate === `cleaned-up`, + cleaning: model.sources[step.source].cleaning, } : undefined + const queryEndedBefore = model.queryEnded + const aCleaningBefore = model.sources.A.cleaning const expected = applyModel(shape, model, step) // The write tag the model gave this step, if it wrote. const tag = model.serial @@ -1033,7 +1195,23 @@ async function runHistory( let thrown: unknown try { if (step.kind === `effect`) { - await mountEffect() + if (!aCleaningBefore) await mountEffect() + } else if (step.kind === `cleanup-hold`) { + if (shapeSources[shape].includes(step.source)) { + sources[step.source].holdCleanup() + pendingCleanups[step.source] ??= sources[step.source].collection + .cleanup({ dependents: true }) + .finally(() => { + pendingCleanups[step.source] = undefined + }) + } + } else if (step.kind === `cleanup-settle`) { + sources[step.source].settleCleanup() + await pendingCleanups[step.source] + } else if (step.kind === `reload-query` && queryEndedBefore) { + // Outside the grammar: the reload of a query a cleanup ended. + } else if (step.kind === `crash` && pre!.retired) { + // Outside the grammar: core crashes only a running Collection. } else if (step.kind === `reload-query`) { const reload = (query as { reload?: () => Promise }).reload?.() if (query.status === `crashed`) @@ -1041,6 +1219,7 @@ async function runHistory( else void reload?.catch(() => undefined) await flushPromises() } else if (step.kind === `restart`) { + await settleCleanups() // The app ends its readers before it cleans up their Collections. await effect?.dispose() await query.cleanup() @@ -1054,15 +1233,16 @@ async function runHistory( const params = sources[step.source].params if (step.kind === `reload`) { // A reload settles only when its new run commits or fails; a - // crashed Collection rejects at once. + // crashed Collection, or one whose cleanup is pending, rejects at + // once. const reload = params.collection.reload() - if (params.collection.status === `crashed`) + if (params.collection.status === `crashed` || pre!.cleaning) thrown = await reload.catch((e: unknown) => e) else void reload.catch(() => undefined) } else if (step.kind === `reload-cleanup-fails`) { sources[step.source].failNextCleanup() const reload = params.collection.reload() - if (params.collection.status === `crashed`) + if (params.collection.status === `crashed` || pre!.cleaning) thrown = await reload.catch((e: unknown) => e) else { void reload.catch(() => undefined) @@ -1159,9 +1339,9 @@ async function runHistory( !model.effect.live, ) expect(effectErrors.length, `${label}: effect source errors`).toBe( - model.effect.live ? 0 : 1, + model.effect.errors, ) - if (step.kind === `effect` && !model.effect.live) { + if (step.kind === `effect` && !aCleaningBefore && !model.effect.live) { expect( listenersOn(sources.A.collection), `${label}: listeners left by the ended effect`, @@ -1200,7 +1380,12 @@ async function runHistory( ).toBe(`health`) } } - if (JSON.stringify(before) !== JSON.stringify(after)) { + // A query a cleanup ended announces nothing more; a read still + // shows the current tree. + if ( + !model.queryEnded && + JSON.stringify(before) !== JSON.stringify(after) + ) { expect( events.at(-1)?.tree, `${label}: last event is current`, @@ -1211,6 +1396,7 @@ async function runHistory( } finally { console.error = consoleError stop() + await settleCleanups() await effect?.dispose() await query.cleanup() await inner?.cleanup({ dependents: true }) @@ -1244,6 +1430,51 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { + 'a reload rejects while adapter cleanup is pending, and starts fresh after it': + [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + { kind: `cleanup-hold`, source: `A` }, + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 2 }, + { kind: `cleanup-settle`, source: `A` }, + on(`reload`, `A`), + on(`ready`, `A`), + ], + "cleanup keeps a failed first load's cause while the status shows it": [ + on(`fail`, `A`), + { kind: `cleanup-hold`, source: `A` }, + on(`report`, `A`), + { kind: `cleanup-settle`, source: `A` }, + ], + 'a cascade cleanup ends the Effect without a source error': [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `cleanup-hold`, source: `A` }, + { kind: `effect` }, + { kind: `cleanup-settle`, source: `A` }, + { kind: `effect` }, + on(`ready`, `A`), + ], + "a crash during a reload keeps the rows as the adapter's baseline": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + on(`crash`, `A`), + { kind: `write`, source: `A`, key: 1 }, + { kind: `commit`, source: `A` }, + ], + "a crash during a reload stops the reload's transaction replacing rows": [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `write`, source: `A`, key: 3 }, + { kind: `commit`, source: `A` }, + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 2 }, + on(`crash`, `A`), + { kind: `commit`, source: `A` }, + ], "a query's reload replaces the rows its failed run left": [ on(`ready`, `A`), on(`fail`, `B`), @@ -1433,6 +1664,15 @@ describe(`source failure and health oracle`, () => { { weight: 1, arbitrary: fc.constant(restart) }, { weight: 1, arbitrary: fc.constant({ kind: `reload-query` } as const) }, { weight: 1, arbitrary: fc.constant({ kind: `effect` } as const) }, + { + weight: 1, + arbitrary: fc + .tuple( + fc.constantFrom(`cleanup-hold` as const, `cleanup-settle` as const), + fc.constantFrom(`A` as const, `B` as const), + ) + .map(([kind, source]): Step => ({ kind, source })), + }, { weight: 6, arbitrary: fc @@ -1457,7 +1697,7 @@ describe(`source failure and health oracle`, () => { oraclePropertyOptions(60, `source-health.history`), )( `generated histories refine the model`, - (shape, steps) => runHistory(shape, steps), + (shape, steps) => withDeadline(runHistory(shape, steps)), 120_000, ) }) From 77dd19e84ececa3fc49a91f120f358febb5b6fc2 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 16:52:54 -0600 Subject: [PATCH 086/106] test(db): generate on-demand demand histories; report a failed replay A second generated source-health property, source-health.on-demand, drives a root window over an on-demand Collection: window moves, settling every pending load ok or failed, source reloads, and the source's own calls. Which loads a window needs is the loader's choice, so the driver reports requested loads from the adapter boundary and the model judges their outcomes: the query's status, its request error, the source's node, and the window's rows. Named histories pin each distinction. It found one defect. A source reload's replay that could not load the query's data again was dropped silently: the query kept a snapshot that no longer updated and showed no error. The replay now reports its failure to the live query, which shows a request error on its node (or fails its first load before readiness) until a later replay succeeds. A mutant that drops the report fails the named replay history, and one that keeps the error after a successful replay fails the clearing history. Co-authored-by: Isaac --- .changeset/source-health-channel.md | 2 +- docs/contributing/glossary.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- docs/guides/error-handling.md | 3 +- packages/db/src/collection/subscription.ts | 4 + .../query/live/collection-config-builder.ts | 7 + .../src/query/live/collection-subscriber.ts | 9 + packages/db/src/types.ts | 2 + packages/db/tests/oracle-config.ts | 1 + .../db/tests/source-health-oracle.test.ts | 329 +++++++++++++++++- 10 files changed, 350 insertions(+), 11 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 8d569d99ce..e562afb543 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -16,7 +16,7 @@ Collections now answer two questions separately: can the data be shown (`status`), and is it still kept up to date (`errors`). Once a query has shown data, a network or backend failure never takes it off the screen. - `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It cannot be recovered in place (`reload()` rejects); `cleanup({ dependents: true })` and a fresh start recover. It reaches every live query and Effect above it. `error` now means only "the first load failed". -- Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. +- Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. After a live query is ready, a failed request for more data (a page, a joined row, or a source reload's replay of its data) is an `error` on the query's own node; the source stays healthy. - `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. - Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 6b4c406462..1340c3de3c 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -73,7 +73,7 @@ until cleanup ends the run. | crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It cannot be recovered in place: nothing restarts it and `reload()` rejects. Only cleanup leaves it, and cleanup discards all of the Collection's state, so a later start is a fresh start. After a crash, the sync adapter's status calls (`markReady()`, `fail()`, `reportError()`, `reportHealthy()`) do nothing. Sync data that the run commits still applies, because closure keeps accepted sync obligations such as the confirmation of a write admitted before it. | Loading error, sync error, or cleanup. | | sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | -| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. | Sync error or request data. | +| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. A source's reload whose replay cannot load the query's data again is also a request error, until a later replay succeeds. | Sync error or request data. | | error tree | The `errors` value of a Collection, live query, or Effect: one node for a Collection; for a query, one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `errors:change` announces a change. | An error list. | | error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index f07d1a009d..7dd18c985b 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. Mutants that restore last round's reload, cleanup, Effect, and replay bugs each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 0b5cec010b..433cfb2d8e 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -172,7 +172,8 @@ availability does not mean the remote request succeeded. Background coordinator retry, where supported, does not change the failed caller's outcome. When a must-refetch truncate cannot reload every active subset, a subscription -keeps its last successful snapshot and reports the subset error. It discards +keeps its last successful snapshot and reports the subset error. A live query +shows it as an `error` on its own node until a later replay succeeds. It discards the incomplete replay batch and keeps later source changes private because they cannot prove a complete replacement. The next truncate retries every active subset. Overlapping truncates form one atomic replay: all in-flight requests diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 8f937b98c7..d360745514 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -95,6 +95,9 @@ type CollectionSubscriptionOptions = { type TruncateReplayPublicationControl = Readonly<{ start: () => void succeed: () => void + // The replay could not load every active demand again; a live query + // reports it on its node. + fail?: (error: Error) => void }> type TruncatePublicationState = { @@ -671,6 +674,7 @@ export class CollectionSubscription ): void { if (this.truncateReplayState !== replayState) return replayState.completion.reject(failure) + this.options.truncateReplayPublication?.fail?.(failure) // Delegated publication already delivered its rows. Only a private buffer // returns the caller's pagination position to the public snapshot; the // private rows and their sent-key tracking stay together for a retry. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ad67a3c0d6..cd8f80dc2b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -648,9 +648,16 @@ export class CollectionConfigBuilder< /** A source's truncate replay loaded these demands again. */ settleReplayedRequests(sourceId: string, planIds: Iterable): void { this.settleRootLoad(sourceId) + this.clearFailedRequest(`replay:${sourceId}`) for (const planId of planIds) this.clearFailedRequest(planId) } + /** A source's truncate replay could not load the query's data again. */ + failReplay(sourceId: string, error: Error): void { + if (this.reportFailedRequest(`replay:${sourceId}`, error)) return + this.failQuery(error.message, error) + } + // After the first ready, a failed load is a request error on this query's // node. It clears when every failed request settles later or is released. private reportFailedRequest(key: string, error: Error): boolean { diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 8bf55962f0..2c7e440026 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -444,6 +444,15 @@ export class CollectionSubscriber< syncRunGeneration, ) }, + // The query keeps its last snapshot, which no longer updates: a + // request error, or a failed first load before readiness. + fail: (error) => { + if ( + syncRunGeneration === + this.collectionConfigBuilder.getSyncRunGeneration() + ) + this.collectionConfigBuilder.failReplay(this.sourceId, error) + }, } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 37ba111ebf..3e2ad1df0d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1265,6 +1265,8 @@ export interface SubscribeChangesOptions< truncateReplayPublication?: { readonly start: () => void readonly succeed: () => void + /** The replay could not load every active demand again. */ + readonly fail?: (error: Error) => void } } diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index cbc5578cee..8d9e7f1daf 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -30,6 +30,7 @@ const staticOracleProperties = [ `sqlite-persistence.optimistic-history`, `trailbase.lifecycle`, `source-health.history`, + `source-health.on-demand`, `source-health.request-errors`, `trailbase.page-event`, `electric.bound-descriptor-history`, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 74288b20bb..80a03518d7 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -127,13 +127,16 @@ * * ## Limits * - * The generated Collections are eager, so a live query cannot become ready - * while a Collection is still loading. The on-demand case, where a - * Collection fails its first load after the live query is ready, is not - * generated; a focused lane enumerates it. Per-row child - * Collections are not in the tree; an include's node shows its source. Pooled histories do not restart: a pooled view ends for good when - * its Collection is cleaned up. The ordering check reads the observed query - * only, not its Collections. + * The main grammar's Collections are eager, so a live query cannot become + * ready while a Collection is still loading; a focused lane enumerates an + * on-demand Collection that fails its first load after the query is ready. + * The on-demand demand grammar covers a root window only: lazy join demand + * keeps its focused lanes, and it settles all pending loads at once (the + * overlapping-load lane owns their order). Per-row child Collections are not + * in the tree; an include's node shows its source. Pooled histories do not + * restart or clean up: a pooled view ends for good when its Collection is + * cleaned up. The ordering check reads the observed query only, not its + * Collections. Grammar exclusions are listed with the history grammar. */ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' @@ -3098,6 +3101,318 @@ describe(`on-demand first load after query readiness`, () => { }) }) +// --------------------------------------------------------------------------- +// On-demand demand grammar +// --------------------------------------------------------------------------- + +/** + * A live query over an on-demand Collection loads only the window it shows. + * The law, from the request-error rules above: a load that fails before the + * query's initial-query readiness fails the query's first load; one that + * fails after it is a request error on the query's own node, and the source + * stays healthy. A request error clears only when a load that started after + * the failure succeeds; a source's reload loads the window again and clears + * it when that replay succeeds. The source's own problems after readiness + * are error tree detail and never change the query's status; its crash + * crashes the query. + * + * The provider has rows 1 to 99. It answers a request without a limit with + * every row, and a limited request with the rows up to the end of the + * window. The query shows two rows ordered by id. Steps: `window` moves the + * window two rows on; `settle-ok` settles every pending load, and any load + * the query requests after it, until none is pending; `settle-fail` rejects + * every pending load at once (the overlapping-load lane owns the order of + * two loads); `reload` reloads the source; `report`, `healthy`, and `fail` + * are the source adapter's calls after readiness; `crash` crashes the + * source. + * + * Which loads a window needs is the loader's choice, so the model does not + * predict requests: the driver reports each load the query requests, as the + * adapter sees it. The model keeps the query's gate, its request error, + * whether a load was requested after that error, the pending loads, the + * window, and the source's node. The comparison checks, after every step, + * the query's status, its own error, the source's node, and, when no load is + * pending and no error shows, the rows in the window. + */ +type DemandStep = + | `window` + | `settle-ok` + | `settle-fail` + | `reload` + | `report` + | `healthy` + | `fail` + | `crash` + +type DemandModel = { + gate: `loading` | `ready` | `error` | `crashed` + requestError: string | null + // A load started after the shown request error. + laterLoad: boolean + pending: number + offset: number + // The window whose load last succeeded; the query shows it. + shown: number + source: { failure: string | null; stopped: string | null; crashed: boolean } + serial: number +} + +// The query requested `count` loads at the adapter boundary. A window move +// that requests none is served from rows the query already loaded. +function requestedLoads( + model: DemandModel, + count: number, + step?: DemandStep, +): void { + if (count === 0) { + if (step === `window` && model.pending === 0) model.shown = model.offset + return + } + model.pending += count + if (model.requestError) model.laterLoad = true +} + +function applyDemandModel(model: DemandModel, step: DemandStep): void { + if (model.gate === `crashed`) return + switch (step) { + case `window`: + if (model.gate === `ready`) model.offset += 2 + return + case `settle-ok`: + if (model.pending === 0) return + model.pending = 0 + if (model.gate === `loading`) model.gate = `ready` + if (model.requestError && model.laterLoad) model.requestError = null + model.shown = model.offset + return + case `settle-fail`: + if (model.pending === 0) return + model.pending = 0 + if (model.gate === `loading`) { + model.gate = `error` + return + } + model.requestError = `root load ${++model.serial} failed` + model.laterLoad = false + // A failed window move keeps the last good window; the app retries + // with setWindow(). + model.offset = model.shown + return + case `reload`: + // The new run is ready inside sync(), so its first commit at once + // clears the node; the replay loads the window again. + if (model.source.crashed) return + model.source.failure = null + model.source.stopped = null + return + case `report`: + if (model.source.stopped) return + model.source.failure = `source problem` + return + case `healthy`: + if (model.source.stopped) return + model.source.failure = null + return + case `fail`: + if (model.source.stopped) return + model.source.failure = null + model.source.stopped = `source gave up` + return + case `crash`: + model.source.crashed = true + model.gate = `crashed` + return + } +} + +async function runDemandHistory(steps: ReadonlyArray) { + const model: DemandModel = { + gate: `loading`, + requestError: null, + laterLoad: false, + pending: 0, + offset: 0, + shown: 0, + source: { failure: null, stopped: null, crashed: false }, + serial: 0, + } + const loads: Array>> = [] + let params!: SyncParams + const source = createCollection({ + id: `health-demand-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (next) => { + params = next + // Rows this sync run has served; a reload's run serves them again. + const served = new Set() + next.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const load = createDeferred() + loads.push(load) + const end = options.limit === undefined ? 99 : model.offset + 2 + return load.promise.then(() => { + next.begin() + for (let id = 1; id <= end; id++) { + if (served.has(id)) continue + served.add(id) + next.write({ type: `insert`, value: { id, parentId: id } }) + } + next.commit() + }) + }, + } + }, + }, + }) + void params + const query = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.id) + .limit(2), + ) + const consoleError = console.error + console.error = () => {} + try { + void query.preload().catch(() => undefined) + await flushPromises() + requestedLoads(model, loads.length) + for (const [index, step] of steps.entries()) { + const label = `step ${index} ${step}` + applyDemandModel(model, step) + let requested = 0 + const before = loads.length + if (step === `window`) { + if (query.status === `ready`) + void Promise.resolve( + query.utils.setWindow({ offset: model.offset, limit: 2 }), + ).catch(() => undefined) + } else if (step === `settle-ok`) { + // Ordered loading may follow a load with more requests; settle until + // the window asks for nothing more. + for (let round = 0; round < 5 && loads.length > 0; round++) { + for (const load of loads.splice(0)) load.resolve() + await flushPromises() + } + requested = 0 + } else if (step === `settle-fail`) { + for (const load of loads.splice(0)) + load.reject(new Error(`root load ${model.serial} failed`)) + } else if (step === `reload`) { + void source.reload().catch(() => undefined) + } else if (step === `crash`) { + source._lifecycle.crash(new Error(`source crashed`)) + } else { + try { + const current = source._lifecycle + void current + if (step === `report`) params.reportError(new Error(`source problem`)) + else if (step === `healthy`) params.reportHealthy() + else params.fail(new Error(`source gave up`)) + } catch { + // After fail(), the adapter's calls throw; the model ignores them. + } + } + await flushPromises() + requested += Math.max( + 0, + loads.length - + (step === `settle-fail` || step === `settle-ok` ? 0 : before), + ) + requestedLoads(model, requested, step) + + expect(query.status, `${label}: query status`).toBe(model.gate) + if (model.gate === `ready`) { + expect( + query.errors.error?.message ?? null, + `${label}: request error`, + ).toBe(model.requestError) + } + const sourceNode = query.errors.sources[0]! + if (!model.source.crashed) { + expect( + sourceNode.failureReason?.message ?? null, + `${label}: source failureReason`, + ).toBe(model.source.stopped ? null : model.source.failure) + expect( + sourceNode.error?.message ?? null, + `${label}: source error`, + ).toBe(model.source.stopped) + } + if ( + model.gate === `ready` && + model.pending === 0 && + model.requestError === null + ) { + expect( + query.toArray.map((row) => row.id), + `${label}: window rows`, + ).toEqual([model.shown + 1, model.shown + 2]) + } + } + } finally { + console.error = consoleError + for (const load of loads.splice(0)) load.resolve() + await query.cleanup() + await source.cleanup({ dependents: true }) + } +} + +describe(`on-demand demand grammar`, () => { + // Named histories, one per distinction the law draws. + const named: Record> = { + 'a failed load after readiness is a request error, and a later load clears it': + [`settle-ok`, `window`, `settle-fail`, `window`, `settle-ok`], + 'a failed first load fails the query': [`settle-fail`], + "a failed source reload's replay is a request error": [ + `settle-ok`, + `window`, + `reload`, + `settle-fail`, + ], + "a successful replay clears the replay's request error": [ + `settle-ok`, + `reload`, + `settle-fail`, + `reload`, + `settle-ok`, + ], + "the source's own problems stay on its node": [ + `settle-ok`, + `report`, + `window`, + `fail`, + `settle-ok`, + `crash`, + ], + } + for (const [name, steps] of Object.entries(named)) + it(name, () => runDemandHistory(steps)) + + const step = fc.constantFrom( + `window`, + `settle-ok`, + `settle-fail`, + `reload`, + `report`, + `healthy`, + `fail`, + `crash`, + ) + fcTest.prop( + [fc.array(step, { maxLength: 10 })], + oraclePropertyOptions(60, `source-health.on-demand`), + )( + `generated demand histories refine the model`, + (steps) => withDeadline(runDemandHistory(steps)), + 120_000, + ) +}) + // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- From 099c8a351fa524c07887691df731084a0ed8e117 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 17:41:57 -0600 Subject: [PATCH 087/106] test(db): type the source-health step helper by its source-step kinds Co-authored-by: Isaac --- .../db/tests/source-health-oracle.test.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 80a03518d7..3a68f84ade 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -167,19 +167,18 @@ type SyncParams = Parameters[`sync`]>[0] type SourceName = `A` | `B` type Shape = `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` +// A source step that takes no other argument. +type SourceStepKind = + | `ready` + | `fail` + | `report` + | `healthy` + | `crash` + | `reload` + | `reload-cleanup-fails` + | `cancel` type Step = - | { - kind: - | `ready` - | `fail` - | `report` - | `healthy` - | `crash` - | `reload` - | `reload-cleanup-fails` - | `cancel` - source: SourceName - } + | { kind: SourceStepKind; source: SourceName } // `write` adds a row to the sync run's open transaction, beginning one if // none is open; `commit` commits it (an empty transaction when none is // open). The transaction stays open across other steps. @@ -1422,10 +1421,7 @@ const shapes: ReadonlyArray = [ `include`, `pooled-nested`, ] -const on = ( - kind: Exclude, - source: SourceName, -): Step => ({ +const on = (kind: SourceStepKind, source: SourceName): Step => ({ kind, source, }) From 4b719d28546c2d48df6c14dea56bc141e198230d Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 18:19:38 -0600 Subject: [PATCH 088/106] fix(db): a live query's reload starts it after a cascade ended it The source-health grammar now generates three cases it excluded: a crash during or after cleanup, a new Effect while the source's cleanup is pending, and a query's reload after a cascade cleanup ended it. The last found a defect: reload() on a cleaned-up live query resolved and did nothing, so the query stayed cleaned-up with no rows. A Collection's reload starts a cleaned-up Collection; the live query now does the same through its preload, which also starts its cleaned-up sources. A mutant that restores the no-op fails the named histories in every shape. The model follows the start order production uses: the query goes through its inputs in order, the first that ends its run decides, and it still starts every later input. A source still cleaning up crashes a query that is starting; that query reads nothing, so it announces nothing more and no cascade ends it. A crash keeps its first cause. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../query/live/collection-config-builder.ts | 6 +- .../db/tests/source-health-oracle.test.ts | 194 +++++++++++++++--- packages/db/tests/zz/probe.test.ts | 76 +++++++ 4 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 packages/db/tests/zz/probe.test.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3c15419e07..894ddb9403 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. Mutants that restore last round's reload, cleanup, Effect, and replay bugs each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. Mutants that restore last round's reload, cleanup, Effect, and replay bugs each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index cd8f80dc2b..88e971d6c9 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -137,8 +137,12 @@ export class CollectionConfigBuilder< */ async reload(): Promise { const query = this.liveQueryCollection - // A crashed query rejects without touching its sources. + // A crashed query rejects without touching its sources. One that is not + // running starts, as any Collection's reload does; its preload is what + // lets it start its sources. if (query?.status === `crashed`) return query._sync.reload() + if (query?.status === `idle` || query?.status === `cleaned-up`) + return query.preload() const failed = this.collectionSources .map(({ collection }) => collection) .filter( diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 3a68f84ade..b09e54be3d 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -89,10 +89,15 @@ * the status shows it, the retired run's calls do nothing, and a reload * rejects. The cascade ends the query and an Effect over the Collection, * without a source error. After cleanup settles, the status is `cleaned-up`, - * and a reload starts a fresh sync run. Outside the grammar: a crash of a - * Collection whose cleanup started, a query's reload after a cleanup ended - * it, a new Effect while A's cleanup is pending, and cleanup of the pooled - * shape. A history that does not settle within 10 seconds fails. A query whose first load failed stays in `error` until its + * and a reload starts a fresh sync run. A crash during cleanup lasts until + * cleanup settles; a crash after it lasts until the next cleanup. An Effect + * created while A's cleanup is pending ends with a source error. A query's + * reload after a cascade starts the query and every cleaned-up Collection it + * reads; it goes through its inputs in order, and the first that ends its run + * decides. A Collection still cleaning up crashes it, and such a query reads + * no Collection, so it announces nothing more and no cascade ends it. + * Outside the grammar: cleanup of the pooled shape, whose restart is an open + * question. A history that does not settle within 10 seconds fails. A query whose first load failed stays in `error` until its * own reload: `reload-query` calls the observed query's `reload()`, which * reloads every Collection below it that shows an error, through a nested * live query, and then the query itself. `restart` cleans up the @@ -214,8 +219,11 @@ type GateModel = { gate: Gate; cause: string | null; reloading: boolean } // the next transaction it begins replaces every row, and until then a diffing // adapter sees no rows to diff against. // `cleaning`: cleanup started and the adapter's cleanup has not settled. +// `retired`: cleanup ended the sync run and no new one has started, so the +// adapter's calls do nothing. type SourceModel = GateModel & { cleaning: boolean + retired: boolean failure: Problem stopped: Problem failureFromReload: boolean @@ -247,6 +255,10 @@ type Model = { // query): it shows `cleaned-up` and announces nothing more. queryEnded: boolean innerEnded: boolean + // The query crashed while it started, because a Collection it reads was + // still cleaning up. It reads no Collection: it announces nothing more, + // and no cascade ends it. + queryDetached: boolean } // An Effect over A. It reaches its gate when A is ready. A crash of A ends @@ -267,6 +279,62 @@ function deriveEffect(model: Model): void { } else if (A.gate === `ready`) effect.gate = true } +// The reload of a query a cleanup ended starts it again, as any Collection's +// reload starts a cleaned-up one, and its start starts each cleaned-up +// Collection it reads. It goes through its inputs in query order, and the +// first that ends its run decides: a Collection still cleaning up crashes it +// (it then reads nothing), and a failed or crashed input fails or crashes +// it. It still starts every later input. +const cleanedUnderQuery = `a source was cleaned up under a live query` +const queryInputs: Record> = { + direct: [`A`], + join: [`A`, `B`], + 'self-join': [`A`], + nested: [`inner`, `B`], + include: [`B`, `A`], + 'pooled-nested': [`inner`], +} +function startQuery( + model: Model, + inputs: ReadonlyArray, +): GateModel { + let result: GateModel = { gate: `loading`, cause: null, reloading: false } + for (const input of inputs) { + const running = result.gate === `loading` + if (input === `inner`) { + const { gate, cause } = model.inner + if (running && (gate === `error` || gate === `crashed`)) + result = { gate, cause, reloading: false } + continue + } + const source = model.sources[input] + // A Collection still cleaning up stops the start, unless the run already + // ended; then the query starts the rest. + if (source.cleaning) { + if (running) + return { gate: `crashed`, cause: cleanedUnderQuery, reloading: false } + continue + } + if (source.gate === `cleaned-up`) restartSource(source) + if (running && (source.gate === `error` || source.gate === `crashed`)) + result = { gate: source.gate, cause: source.cause, reloading: false } + } + return result +} +function restartEndedQuery(shape: Shape, model: Model): void { + model.queryEnded = false + model.selfReloads = [] + if (shape === `nested` && model.innerEnded) { + model.innerEnded = false + model.inner = startQuery(model, [`A`]) + } else model.innerEnded = false + model.query = startQuery(model, queryInputs[shape]) + deriveQueries(shape, model) + deriveEffect(model) + model.queryDetached = + model.query.gate === `crashed` && model.query.cause === cleanedUnderQuery +} + // A fresh sync run of a cleaned-up Collection: nothing of the old run stays. function restartSource(source: SourceModel): void { Object.assign(source, { @@ -277,6 +345,7 @@ function restartSource(source: SourceModel): void { stopped: null, failureFromReload: false, cleaning: false, + retired: false, rows: new Map(), open: null, armed: false, @@ -307,6 +376,7 @@ function applyCleanup( if (source.cleaning || source.gate === `cleaned-up`) return Object.assign(source, { cleaning: true, + retired: true, reloading: false, failure: null, stopped: null, @@ -315,7 +385,7 @@ function applyCleanup( open: null, armed: false, }) - model.queryEnded = true + if (!model.queryDetached) model.queryEnded = true model.selfReloads = [] if (name === `A`) { model.innerEnded = true @@ -385,6 +455,7 @@ function freshModel(): Model { stopped: null, failureFromReload: false, cleaning: false, + retired: false, rows: new Map(), open: null, armed: false, @@ -398,6 +469,7 @@ function freshModel(): Model { effect: { live: true, gate: false, errors: 0 }, queryEnded: false, innerEnded: false, + queryDetached: false, } } @@ -437,7 +509,7 @@ function deriveQueries(shape: Shape, model: Model): void { return } if (shape === `nested`) { - model.inner = deriveGate(model.inner, gates([`A`])) + if (!model.innerEnded) model.inner = deriveGate(model.inner, gates([`A`])) model.query = deriveGate(model.query, [model.inner, model.sources.B]) } else if (shape === `pooled-nested`) { // A pooled view has no gate of its own: it shows its source's. @@ -462,9 +534,13 @@ function applyModel( return `ok` } if (step.kind === `effect`) { - // Excluded while A's cleanup is pending. Over a cleaned-up A, the new - // Effect's subscription starts A's next sync run. - if (model.sources.A.cleaning) return `ok` + // An Effect created while A's cleanup is pending cannot read A: it ends + // through onSourceError. Over a cleaned-up A, the new Effect's + // subscription starts A's next sync run. + if (model.sources.A.cleaning) { + model.effect = { live: false, gate: false, errors: 1 } + return `ok` + } if (model.sources.A.gate === `cleaned-up`) restartSource(model.sources.A) model.effect = { live: true, gate: false, errors: 0 } deriveEffect(model) @@ -475,9 +551,12 @@ function applyModel( return `ok` } if (step.kind === `reload-query`) { - // A pooled view has no reload of its own. A query a cleanup ended is - // outside the grammar for its reload. - if (shape === `pooled-nested` || model.queryEnded) return `ok` + // A pooled view has no reload of its own. + if (shape === `pooled-nested`) return `ok` + if (model.queryEnded) { + restartEndedQuery(shape, model) + return `ok` + } if (model.query.gate === `crashed`) return `throws` // The query reloads every Collection below it that shows an error, // through any nested live query; a reload in flight keeps its run. @@ -531,16 +610,23 @@ function applyModel( const stopped = !source.reloading && (source.gate === `error` || source.stopped !== null) // While cleanup is pending, and after it, the retired run's calls do - // nothing. A reload is a start: it rejects during cleanup, and after it a - // fresh sync run starts. Core crashes only a running Collection, so a - // crash there is outside the grammar. - if (source.cleaning || source.gate === `cleaned-up`) { - if (step.kind === `reload` || step.kind === `reload-cleanup-fails`) { - if (source.cleaning) return `throws` + // nothing. A reload is a start: it rejects during cleanup or on a crash, + // and after cleanup it starts a fresh sync run. A crash still applies: + // only cleanup leaves it, so a crash during cleanup ends when cleanup + // settles. + if (source.retired) { + if (step.kind === `crash`) { + // A crash keeps the first cause. + if (source.gate !== `crashed`) { + source.gate = `crashed` + source.cause = `${step.source} crashed` + } + } else if (step.kind === `reload` || step.kind === `reload-cleanup-fails`) { + if (source.cleaning || source.gate === `crashed`) return `throws` restartSource(source) - deriveQueries(shape, model) - deriveEffect(model) } + deriveQueries(shape, model) + deriveEffect(model) return `ok` } if (step.kind !== `crash` && source.gate === `crashed`) { @@ -797,7 +883,9 @@ function expectedProblems(node: ExpectedNode): Array { function problemOf(node: ErrorTreeNode): ProblemSummary { return [ node.alias, - (node.error ?? node.failureReason)!.message.replace(cleanupPrefix, ``), + (node.error ?? node.failureReason)!.message + .replace(cleanupPrefix, ``) + .replace(cleanedUnderQueryMessage, cleanedUnderQuery), ] } @@ -981,10 +1069,19 @@ type PooledView = Subject[`query`] & { // A failed adapter cleanup reaches the node as a SyncCleanupError, whose // message names the Collection; the model knows only the adapter's message. const cleanupPrefix = /^Collection ".*" sync cleanup function threw an error: / +// The message names the Collection and the query; the model names the case. +const cleanedUnderQueryMessage = + /^Source collection '.*' was manually cleaned up while live query '.*' depends on it\..*$/s function project(node: ErrorTreeNode): ExpectedNode { const problem = (info: ErrorTreeNode[`error`]): ExpectedProblem => - info ? { message: info.message.replace(cleanupPrefix, ``) } : null + info + ? { + message: info.message + .replace(cleanupPrefix, ``) + .replace(cleanedUnderQueryMessage, cleanedUnderQuery), + } + : null return { alias: node.alias, kind: node.kind, @@ -1138,7 +1235,7 @@ async function runHistory( } // Cleanup without subscribers drops event listeners, so each sync run // registers its own. - const begin = () => { + const listen = () => { stop() announced = query.status const record = (kind: Event[`kind`]) => { @@ -1157,6 +1254,9 @@ async function runHistory( stopStatus() stopHealth() } + } + const begin = () => { + listen() start() void mountEffect() } @@ -1189,7 +1289,6 @@ async function runHistory( } : undefined const queryEndedBefore = model.queryEnded - const aCleaningBefore = model.sources.A.cleaning const expected = applyModel(shape, model, step) // The write tag the model gave this step, if it wrote. const tag = model.serial @@ -1197,7 +1296,7 @@ async function runHistory( let thrown: unknown try { if (step.kind === `effect`) { - if (!aCleaningBefore) await mountEffect() + await mountEffect() } else if (step.kind === `cleanup-hold`) { if (shapeSources[shape].includes(step.source)) { sources[step.source].holdCleanup() @@ -1211,9 +1310,13 @@ async function runHistory( sources[step.source].settleCleanup() await pendingCleanups[step.source] } else if (step.kind === `reload-query` && queryEndedBefore) { - // Outside the grammar: the reload of a query a cleanup ended. - } else if (step.kind === `crash` && pre!.retired) { - // Outside the grammar: core crashes only a running Collection. + // The query starts again; a source still cleaning up crashes it, + // which its status shows. Its cleanup dropped its listeners. + listen() + void (query as { reload?: () => Promise }) + .reload?.() + .catch(() => undefined) + await flushPromises() } else if (step.kind === `reload-query`) { const reload = (query as { reload?: () => Promise }).reload?.() if (query.status === `crashed`) @@ -1343,7 +1446,7 @@ async function runHistory( expect(effectErrors.length, `${label}: effect source errors`).toBe( model.effect.errors, ) - if (step.kind === `effect` && !aCleaningBefore && !model.effect.live) { + if (step.kind === `effect` && !model.effect.live) { expect( listenersOn(sources.A.collection), `${label}: listeners left by the ended effect`, @@ -1386,6 +1489,7 @@ async function runHistory( // shows the current tree. if ( !model.queryEnded && + !model.queryDetached && JSON.stringify(before) !== JSON.stringify(after) ) { expect( @@ -1429,6 +1533,38 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { + 'a crash during cleanup lasts until cleanup settles': [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `cleanup-hold`, source: `A` }, + on(`crash`, `A`), + on(`reload`, `A`), + { kind: `cleanup-settle`, source: `A` }, + on(`crash`, `A`), + on(`reload`, `A`), + ], + "a query's reload after a cascade starts it and its sources again": [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `cleanup-hold`, source: `A` }, + { kind: `cleanup-settle`, source: `A` }, + { kind: `reload-query` }, + on(`ready`, `A`), + on(`ready`, `B`), + ], + "a query's reload while a source cleans up crashes it": [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `cleanup-hold`, source: `A` }, + { kind: `reload-query` }, + { kind: `cleanup-settle`, source: `A` }, + ], + 'an Effect created during cleanup ends with a source error': [ + on(`ready`, `A`), + { kind: `cleanup-hold`, source: `A` }, + { kind: `effect` }, + { kind: `cleanup-settle`, source: `A` }, + ], 'a reload rejects while adapter cleanup is pending, and starts fresh after it': [ on(`ready`, `A`), diff --git a/packages/db/tests/zz/probe.test.ts b/packages/db/tests/zz/probe.test.ts new file mode 100644 index 0000000000..2cbf85e6c1 --- /dev/null +++ b/packages/db/tests/zz/probe.test.ts @@ -0,0 +1,76 @@ +import { it } from 'vitest' +import { createCollection, createLiveQueryCollection } from '../../src/index' +import { createEffect } from '../../src/query/effect' +import { createDeferred } from '../../src/deferred' + +const mk = (name: string, holds: Record, params: Record) => createCollection({ id: `zz-${name}-${Math.random()}`, getKey: (r) => r.id, gcTime: 0, + sync: { sync: (p) => { params[name] = p; p.begin(); p.write({ type: `insert`, value: { id: 1 } }); p.commit(); p.markReady(); return () => holds[name]?.promise } } }) +it(`1: crash during and after cleanup`, async () => { + const holds: any = {}, params: any = {} + const A = mk(`A`, holds, params); await A.preload() + holds.A = createDeferred() + const c = A.cleanup() + A._lifecycle.crash(new Error(`crash during cleanup`)) + console.log(`1 during`, A.status, A.errors.error?.message) + holds.A.resolve(); await c.catch((e) => console.log(`1 cleanup rejected`, String(e))) + console.log(`1 settled`, A.status, A.errors.error?.message ?? null) + try { A._lifecycle.crash(new Error(`crash after cleanup`)) } catch (e) { console.log(`1 crash after threw`, String(e)) } + console.log(`1 after`, A.status, A.errors.error?.message ?? null) + let r = `?`; await A.reload().then(() => (r = `resolved`), (e) => (r = `rejected ${e.constructor.name}`)); console.log(`1 reload`, r, A.status) + holds.A = undefined; await A.cleanup(); await A.preload().then(() => console.log(`1 fresh`, A.status, A.size), (e) => console.log(`1 fresh rejected`, String(e))) +}) +it(`2: query reload after cascade ended it`, async () => { + const holds: any = {}, params: any = {} + const A = mk(`A`, holds, params) + const q = createLiveQueryCollection((qq) => qq.from({ a: A })) + await q.preload() + await A.cleanup({ dependents: true }) + console.log(`2 ended`, q.status, A.status) + let r = `?`; const p = q.reload().then(() => (r = `resolved`), (e) => (r = `rejected ${e.constructor.name}`)) + await new Promise((x) => setTimeout(x, 10)); await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) + console.log(`2 after query reload`, r, q.status, q.size, `A`, A.status) +}) +it(`3: effect created during pending cleanup`, async () => { + const holds: any = {}, params: any = {} + const A = mk(`A`, holds, params); await A.preload() + holds.A = createDeferred() + const c = A.cleanup({ dependents: true }) + const errs: Array = [] + let threw = `none` + let e: any + try { e = createEffect({ query: (qq) => qq.from({ row: A }), onBatch: () => {}, onSourceError: (x) => errs.push(String(x)) }) } catch (x) { threw = String(x) } + await new Promise((x) => setTimeout(x, 0)) + console.log(`3 during`, threw, e?.disposed, errs, A.status) + holds.A.resolve(); await c.catch(() => {}) + await new Promise((x) => setTimeout(x, 0)) + console.log(`3 settled`, e?.disposed, errs, A.status) + await e?.dispose() +}) +it(`2b: query preload after cascade ended it`, async () => { + const holds: any = {}, params: any = {} + const A = mk(`A`, holds, params) + const q = createLiveQueryCollection((qq) => qq.from({ a: A })) + await q.preload() + await A.cleanup({ dependents: true }) + const p = q.preload() + await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) + console.log(`2b after query preload`, q.status, q.size, `A`, A.status) + const sub = q.subscribeChanges(() => {}) + await new Promise((x) => setTimeout(x, 20)) + console.log(`2b after subscribe`, q.status, q.size, `A`, A.status) + sub.unsubscribe() +}) +it(`2c: query reload while its source is cleaning`, async () => { + const holds: any = {}, params: any = {} + const A = mk(`A`, holds, params) + const q = createLiveQueryCollection((qq) => qq.from({ a: A })) + await q.preload() + holds.A = createDeferred() + const c = A.cleanup({ dependents: true }) + await new Promise((x) => setTimeout(x, 0)) + let r = `?`; const p = q.reload().then(() => (r = `resolved`), (e) => (r = `rejected ${e.constructor.name} ${e.message}`)) + await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) + console.log(`2c during`, r, q.status, JSON.stringify(q.errors.error?.message ?? null), `A`, A.status) + holds.A.resolve(); await c.catch(() => {}); await new Promise((x) => setTimeout(x, 10)) + console.log(`2c settled`, q.status, `A`, A.status) +}) From 82cb87b6962b435db61fe7cbb08a9d73c218b186 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 19:34:27 -0600 Subject: [PATCH 089/106] test(db): generate lazy join demand histories; retire a request error with its key A third generated source-health property, source-health.lazy-demand, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls. The driver reports each load's requested keys from the adapter boundary, and the model judges the query's status, its request error, and each parent's child. It found one defect: a request error outlived the demand for its failed key. When the query stopped needing that key, the error stayed until some other load of the plan settled, and while a retry for the key was pending it stayed until the retry did. The demand controller now records which keys a retry reloads, and an update that no longer needs any failed key clears the plan's error. Mutants that drop the clear, or the retry tracking, fail named histories. Open, and recorded in the coverage map: a demand change while a source reload's replay is in flight can clear the request error before the failed key loads again. The lazy grammar does not check the request error during or after a replay until that is decided. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 1 - .../query/live/collection-config-builder.ts | 3 +- .../src/query/live/collection-subscriber.ts | 3 + .../query/live/subset-demand-controller.ts | 21 + packages/db/tests/oracle-config.ts | 1 + .../db/tests/source-health-oracle.test.ts | 372 +++++++++++++++++- 7 files changed, 397 insertions(+), 6 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 894ddb9403..e8c35c4206 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. Mutants that restore last round's reload, cleanup, Effect, and replay bugs each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the lazy demand grammar does not check the request error during or after a replay until this is decided. A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 08092ad4ba..1f25bb826a 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -392,7 +392,6 @@ "attach": "gF", "sortedExactValues": "gG", "reachedReady": "cc", - "clearFailedRequest": "dp", "requestErrors": "gH", "reportFailedRequest": "gJ", "includeSourceIds": "gL", diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 88e971d6c9..e47942b84b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -671,7 +671,8 @@ export class CollectionConfigBuilder< return true } - private clearFailedRequest(key: string): void { + /** A request settled later, or the query no longer needs its data. */ + clearFailedRequest(key: string): void { this.liveQueryCollection?._lifecycle.clearRequestError(key) } diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 2c7e440026..78b2f0513b 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -221,6 +221,9 @@ export class CollectionSubscriber< this.collectionConfigBuilder.retireDemand(plan.id) return } + // The query no longer needs the data whose load failed. + if (update.failureRetired) + this.collectionConfigBuilder.clearFailedRequest(plan.id) const generation = this.collectionConfigBuilder.beginDemand( plan.id, diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index 01e38c9d38..bef3a8cf55 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -18,6 +18,8 @@ type DemandSegment = { ready: LoadSubsetRequestResult state: `starting` | `pending` | `settled` | `failed` replaces?: Array + // Keys whose earlier load failed and that this segment loads again. + retries?: Map } type DemandState = { @@ -29,6 +31,8 @@ export type DemandUpdate = { changed: boolean empty: boolean ready: Promise> | true + // The demand no longer needs any key whose load failed. + failureRetired?: boolean } /** @@ -57,6 +61,20 @@ export class SubsetDemandController { (segment) => segment.state === `failed` && intersects(segment.keys, nextKeys), ) + // Keys whose load failed: in a failed segment, or retried by one still + // loading. + const failedKeys = new Map( + (previous?.segments ?? []).flatMap((segment) => + segment.state === `failed` + ? [...segment.keys] + : segment.state === `settled` + ? [] + : [...(segment.retries ?? [])], + ), + ) + const failureRetired = + failedKeys.size > 0 && + [...failedKeys.keys()].every((key) => !nextKeys.has(key)) if ( previous && equalKeySets(previous.keys, nextKeys) && @@ -108,6 +126,8 @@ export class SubsetDemandController { if (requestedKeys.size > 0) { const segment = createSegment(plan, requestedKeys) if (replace) segment.replaces = [...segments] + const retried = [...failedKeys].filter(([key]) => nextKeys.has(key)) + if (retried.length > 0) segment.retries = new Map(retried) segments.push(segment) const started = startSegment(subscription, segment, () => this.warnUnoptimized(plan), @@ -144,6 +164,7 @@ export class SubsetDemandController { changed: true, empty: false, ready: pending.length > 0 ? Promise.all(pending) : true, + failureRetired, } } diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8d9e7f1daf..59077ed8e8 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -31,6 +31,7 @@ const staticOracleProperties = [ `trailbase.lifecycle`, `source-health.history`, `source-health.on-demand`, + `source-health.lazy-demand`, `source-health.request-errors`, `trailbase.page-event`, `electric.bound-descriptor-history`, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index b09e54be3d..86313c086c 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -135,9 +135,11 @@ * The main grammar's Collections are eager, so a live query cannot become * ready while a Collection is still loading; a focused lane enumerates an * on-demand Collection that fails its first load after the query is ready. - * The on-demand demand grammar covers a root window only: lazy join demand - * keeps its focused lanes, and it settles all pending loads at once (the - * overlapping-load lane owns their order). Per-row child Collections are not + * The demand grammars settle all pending loads at once (the overlapping-load + * lane owns their order). The lazy demand grammar does not yet check the + * request error while a source reload's replay is in flight or after one + * failed: a demand change during the replay can clear the error before its + * key loads again, which is open. Per-row child Collections are not * in the tree; an include's node shows its source. Pooled histories do not * restart or clean up: a pooled view ends for good when its Collection is * cleaned up. The ordering check reads the observed query only, not its @@ -3545,6 +3547,370 @@ describe(`on-demand demand grammar`, () => { ) }) +// --------------------------------------------------------------------------- +// Lazy join demand grammar +// --------------------------------------------------------------------------- + +/** + * A live query that left-joins an eager parent Collection to an on-demand + * child Collection loads the children of the parents it shows. The law: a + * load that fails before initial-query readiness fails the query's first + * load. After it, a failed load is a request error on the query's node while + * the query still needs a key whose load failed; it clears when a later load + * of that key succeeds, or when the query no longer needs the key. A source + * reload replays the query's demand; a failed replay is a request error + * until a later replay succeeds. Each parent shows its child once a load of + * the child's key succeeded. + * + * Steps: `parent` adds parent 1, 2, or 3, and `unparent` removes it; the + * other steps are as in the window grammar. The driver reports each load's + * requested keys from the adapter boundary, and the model judges outcomes. + * The comparison checks the query's status, whether its node shows an + * error, and, when no load is pending, each parent row and its child. After a + * failed replay the query keeps its last complete snapshot private until a + * replay succeeds; the model does not track that snapshot, so the rows check + * waits for the replay to succeed. + */ +type LazyStep = + | { kind: `parent` | `unparent`; key: RowKey } + | { + kind: `settle-ok` | `settle-fail` | `reload` | `report` | `fail` | `crash` + } + +type LazyModel = { + gate: `loading` | `ready` | `error` | `crashed` + parents: Set + loaded: Set + // Needed keys whose last load failed after readiness. + failed: Set + // A source reload's replay is in flight: every load the query starts + // joins it, inside its publication barrier. + replayActive: boolean + // Needed keys a failed replay could not load again. + replayFailed: Set + pending: Array<{ keys: Set; replay: boolean }> + source: { failure: string | null; stopped: string | null; crashed: boolean } +} + +// Keys a subset request asks for, read from its predicate's literal values. +function requestedKeys(options: LoadSubsetOptions): Set { + const keys = new Set() + const visit = (node: unknown): void => { + if (typeof node === `number`) keys.add(node) + else if (Array.isArray(node)) node.forEach(visit) + else if (node && typeof node === `object`) { + const { type, value, args } = node as { + type?: string + value?: unknown + args?: Array + } + if (type === `val`) visit(value) + else args?.forEach(visit) + } + } + visit(options.where) + return keys +} + +// During the first load, the query is ready once no pending load is for a +// key it still needs: a demand it retired no longer holds readiness back. +function deriveLazyGate(model: LazyModel): void { + if (model.gate !== `loading`) return + const blocking = model.pending.some(({ keys }) => + [...keys].some((key) => model.parents.has(key)), + ) + if (!blocking) model.gate = `ready` +} + +function applyLazyModel(model: LazyModel, step: LazyStep): void { + if (model.gate === `crashed`) return + switch (step.kind) { + case `parent`: + model.parents.add(step.key) + return + case `unparent`: + model.parents.delete(step.key) + model.failed.delete(step.key) + model.replayFailed.delete(step.key) + return + case `settle-ok`: + for (const { keys, replay } of model.pending) { + for (const key of keys) { + model.loaded.add(key) + model.failed.delete(key) + } + if (replay) model.replayFailed.clear() + } + model.pending = [] + model.replayActive = false + if (model.gate === `loading`) model.gate = `ready` + return + case `settle-fail`: + if (model.pending.length === 0) return + if (model.gate === `loading`) { + model.gate = `error` + } else { + for (const { keys, replay } of model.pending) { + // A replay fails the query's data only for demand it still needs. + for (const key of keys) { + if (!model.parents.has(key)) continue + if (replay) model.replayFailed.add(key) + else model.failed.add(key) + } + } + } + model.pending = [] + model.replayActive = false + return + case `reload`: + if (model.source.crashed) return + // The replay loads the demand again; a key it loads is loaded again. + model.source.failure = null + model.source.stopped = null + // The retired run's loads no longer apply, and its truncate drops the + // loaded children until the replay loads them again. + model.pending = [] + model.loaded = new Set() + model.replayActive = true + return + case `report`: + if (!model.source.stopped) model.source.failure = `source problem` + return + case `fail`: + if (!model.source.stopped) { + model.source.failure = null + model.source.stopped = `source gave up` + } + return + case `crash`: + model.source.crashed = true + model.gate = `crashed` + return + } +} + +async function runLazyHistory(steps: ReadonlyArray) { + const model: LazyModel = { + gate: `loading`, + parents: new Set([1]), + loaded: new Set(), + failed: new Set(), + replayActive: false, + replayFailed: new Set(), + pending: [], + source: { failure: null, stopped: null, crashed: false }, + } + const loads: Array>> = [] + const requests: Array> = [] + let parentParams!: SyncParams + let childParams!: SyncParams + const parents = createCollection({ + id: `health-lazy-parents-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: (params) => { + parentParams = params + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + params.markReady() + }, + }, + }) + const children = createCollection({ + id: `health-lazy-children-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + childParams = params + // Children this sync run has served; a reload's run serves them + // again. + const served = new Set() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const keys = requestedKeys(options) + requests.push(keys) + const load = createDeferred() + loads.push(load) + return load.promise.then(() => { + params.begin() + for (const key of keys) { + if (served.has(key)) continue + served.add(key) + params.write({ + type: `insert`, + value: { id: key * 10, parentId: key }, + }) + } + params.commit() + }) + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q + .from({ parent: parents }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + ) + const consoleError = console.error + console.error = () => {} + // Requests made since the last report, tagged with whether a source + // reload's replay made them. + const report = () => { + for (const keys of requests.splice(0)) + model.pending.push({ keys, replay: model.replayActive }) + } + try { + void query.preload().catch(() => undefined) + await flushPromises() + report() + for (const [index, step] of steps.entries()) { + const label = `step ${index} ${JSON.stringify(step)}` + applyLazyModel(model, step) + if (step.kind === `parent` || step.kind === `unparent`) { + parentParams.begin() + parentParams.write({ + type: step.kind === `parent` ? `insert` : `delete`, + value: { id: step.key, parentId: step.key }, + }) + parentParams.commit() + } else if (step.kind === `settle-ok`) { + for (let round = 0; round < 5 && loads.length > 0; round++) { + for (const load of loads.splice(0)) load.resolve() + await flushPromises() + if (round > 0) report() + } + } else if (step.kind === `settle-fail`) { + for (const load of loads.splice(0)) + load.reject(new Error(`child load failed`)) + } else if (step.kind === `reload`) { + void children.reload().catch(() => undefined) + } else if (step.kind === `crash`) { + children._lifecycle.crash(new Error(`children crashed`)) + } else { + try { + if (step.kind === `report`) + childParams.reportError(new Error(`source problem`)) + else childParams.fail(new Error(`source gave up`)) + } catch { + // After fail(), the adapter's calls throw; the model ignores them. + } + } + await flushPromises() + report() + deriveLazyGate(model) + + expect(query.status, `${label}: query status`).toBe(model.gate) + // Open: a demand change while a source reload's replay is in flight + // can clear a request error before its key loads again (see the + // coverage map). Until that is settled, the check waits for the + // replay to succeed; it still checks the error a failed replay shows. + const replayOpen = + (model.replayActive || model.replayFailed.size > 0) && + step.kind !== `settle-fail` + if (model.gate === `ready` && !replayOpen) { + expect(query.errors.error !== null, `${label}: request error`).toBe( + model.failed.size > 0 || model.replayFailed.size > 0, + ) + } + // After a failed replay the query keeps its last complete snapshot + // private, which the model does not track. + if ( + model.gate === `ready` && + model.pending.length === 0 && + model.replayFailed.size === 0 + ) { + const rows = query.toArray.map( + (row: { parent: Row; child?: Row }) => + [row.parent.id, row.child?.id ?? null] as const, + ) + expect( + [...rows].sort((a, b) => a[0] - b[0]), + `${label}: rows`, + ).toEqual( + [...model.parents] + .sort((a, b) => a - b) + .map((key) => [key, model.loaded.has(key) ? key * 10 : null]), + ) + } + } + } finally { + console.error = consoleError + for (const load of loads.splice(0)) load.resolve() + await query.cleanup() + await parents.cleanup() + await children.cleanup({ dependents: true }) + } +} + +describe(`lazy join demand grammar`, () => { + const key = fc.constantFrom(1, 2, 3) + const step: fc.Arbitrary = fc.oneof( + key.map((k): LazyStep => ({ kind: `parent`, key: k })), + key.map((k): LazyStep => ({ kind: `unparent`, key: k })), + fc + .constantFrom( + `settle-ok` as const, + `settle-fail` as const, + `reload` as const, + `report` as const, + `fail` as const, + `crash` as const, + ) + .map((kind): LazyStep => ({ kind })), + ) + const named: Record> = { + 'a failed first load fails the query': [{ kind: `settle-fail` }], + 'a later load of the failed key clears its request error': [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `settle-ok` }, + ], + 'a request error clears when the query no longer needs the failed key': [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `parent`, key: 3 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 3 }, + ], + 'a pending retry does not hold a request error for a key the query no longer needs': + [ + { kind: `settle-ok` }, + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 1 }, + { kind: `settle-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 1 }, + ], + "a failed source reload's replay is a request error": [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + ], + } + for (const [name, steps] of Object.entries(named)) + it(name, () => runLazyHistory(steps)) + fcTest.prop( + [fc.array(step, { maxLength: 10 })], + oraclePropertyOptions(60, `source-health.lazy-demand`), + )( + `generated lazy demand histories refine the model`, + (steps) => withDeadline(runLazyHistory(steps)), + 120_000, + ) +}) + // --------------------------------------------------------------------------- // Effect lane // --------------------------------------------------------------------------- From 912d03fad53d6fbee6e3d499536012fb34488552 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:36:23 +0000 Subject: [PATCH 090/106] ci: apply automated fixes --- packages/db/tests/zz/probe.test.ts | 107 +++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 20 deletions(-) diff --git a/packages/db/tests/zz/probe.test.ts b/packages/db/tests/zz/probe.test.ts index 2cbf85e6c1..a4bfa88150 100644 --- a/packages/db/tests/zz/probe.test.ts +++ b/packages/db/tests/zz/probe.test.ts @@ -3,51 +3,104 @@ import { createCollection, createLiveQueryCollection } from '../../src/index' import { createEffect } from '../../src/query/effect' import { createDeferred } from '../../src/deferred' -const mk = (name: string, holds: Record, params: Record) => createCollection({ id: `zz-${name}-${Math.random()}`, getKey: (r) => r.id, gcTime: 0, - sync: { sync: (p) => { params[name] = p; p.begin(); p.write({ type: `insert`, value: { id: 1 } }); p.commit(); p.markReady(); return () => holds[name]?.promise } } }) +const mk = ( + name: string, + holds: Record, + params: Record, +) => + createCollection({ + id: `zz-${name}-${Math.random()}`, + getKey: (r) => r.id, + gcTime: 0, + sync: { + sync: (p) => { + params[name] = p + p.begin() + p.write({ type: `insert`, value: { id: 1 } }) + p.commit() + p.markReady() + return () => holds[name]?.promise + }, + }, + }) it(`1: crash during and after cleanup`, async () => { - const holds: any = {}, params: any = {} - const A = mk(`A`, holds, params); await A.preload() + const holds: any = {}, + params: any = {} + const A = mk(`A`, holds, params) + await A.preload() holds.A = createDeferred() const c = A.cleanup() A._lifecycle.crash(new Error(`crash during cleanup`)) console.log(`1 during`, A.status, A.errors.error?.message) - holds.A.resolve(); await c.catch((e) => console.log(`1 cleanup rejected`, String(e))) + holds.A.resolve() + await c.catch((e) => console.log(`1 cleanup rejected`, String(e))) console.log(`1 settled`, A.status, A.errors.error?.message ?? null) - try { A._lifecycle.crash(new Error(`crash after cleanup`)) } catch (e) { console.log(`1 crash after threw`, String(e)) } + try { + A._lifecycle.crash(new Error(`crash after cleanup`)) + } catch (e) { + console.log(`1 crash after threw`, String(e)) + } console.log(`1 after`, A.status, A.errors.error?.message ?? null) - let r = `?`; await A.reload().then(() => (r = `resolved`), (e) => (r = `rejected ${e.constructor.name}`)); console.log(`1 reload`, r, A.status) - holds.A = undefined; await A.cleanup(); await A.preload().then(() => console.log(`1 fresh`, A.status, A.size), (e) => console.log(`1 fresh rejected`, String(e))) + let r = `?` + await A.reload().then( + () => (r = `resolved`), + (e) => (r = `rejected ${e.constructor.name}`), + ) + console.log(`1 reload`, r, A.status) + holds.A = undefined + await A.cleanup() + await A.preload().then( + () => console.log(`1 fresh`, A.status, A.size), + (e) => console.log(`1 fresh rejected`, String(e)), + ) }) it(`2: query reload after cascade ended it`, async () => { - const holds: any = {}, params: any = {} + const holds: any = {}, + params: any = {} const A = mk(`A`, holds, params) const q = createLiveQueryCollection((qq) => qq.from({ a: A })) await q.preload() await A.cleanup({ dependents: true }) console.log(`2 ended`, q.status, A.status) - let r = `?`; const p = q.reload().then(() => (r = `resolved`), (e) => (r = `rejected ${e.constructor.name}`)) - await new Promise((x) => setTimeout(x, 10)); await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) + let r = `?` + const p = q.reload().then( + () => (r = `resolved`), + (e) => (r = `rejected ${e.constructor.name}`), + ) + await new Promise((x) => setTimeout(x, 10)) + await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) console.log(`2 after query reload`, r, q.status, q.size, `A`, A.status) }) it(`3: effect created during pending cleanup`, async () => { - const holds: any = {}, params: any = {} - const A = mk(`A`, holds, params); await A.preload() + const holds: any = {}, + params: any = {} + const A = mk(`A`, holds, params) + await A.preload() holds.A = createDeferred() const c = A.cleanup({ dependents: true }) const errs: Array = [] let threw = `none` let e: any - try { e = createEffect({ query: (qq) => qq.from({ row: A }), onBatch: () => {}, onSourceError: (x) => errs.push(String(x)) }) } catch (x) { threw = String(x) } + try { + e = createEffect({ + query: (qq) => qq.from({ row: A }), + onBatch: () => {}, + onSourceError: (x) => errs.push(String(x)), + }) + } catch (x) { + threw = String(x) + } await new Promise((x) => setTimeout(x, 0)) console.log(`3 during`, threw, e?.disposed, errs, A.status) - holds.A.resolve(); await c.catch(() => {}) + holds.A.resolve() + await c.catch(() => {}) await new Promise((x) => setTimeout(x, 0)) console.log(`3 settled`, e?.disposed, errs, A.status) await e?.dispose() }) it(`2b: query preload after cascade ended it`, async () => { - const holds: any = {}, params: any = {} + const holds: any = {}, + params: any = {} const A = mk(`A`, holds, params) const q = createLiveQueryCollection((qq) => qq.from({ a: A })) await q.preload() @@ -61,16 +114,30 @@ it(`2b: query preload after cascade ended it`, async () => { sub.unsubscribe() }) it(`2c: query reload while its source is cleaning`, async () => { - const holds: any = {}, params: any = {} + const holds: any = {}, + params: any = {} const A = mk(`A`, holds, params) const q = createLiveQueryCollection((qq) => qq.from({ a: A })) await q.preload() holds.A = createDeferred() const c = A.cleanup({ dependents: true }) await new Promise((x) => setTimeout(x, 0)) - let r = `?`; const p = q.reload().then(() => (r = `resolved`), (e) => (r = `rejected ${e.constructor.name} ${e.message}`)) + let r = `?` + const p = q.reload().then( + () => (r = `resolved`), + (e) => (r = `rejected ${e.constructor.name} ${e.message}`), + ) await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) - console.log(`2c during`, r, q.status, JSON.stringify(q.errors.error?.message ?? null), `A`, A.status) - holds.A.resolve(); await c.catch(() => {}); await new Promise((x) => setTimeout(x, 10)) + console.log( + `2c during`, + r, + q.status, + JSON.stringify(q.errors.error?.message ?? null), + `A`, + A.status, + ) + holds.A.resolve() + await c.catch(() => {}) + await new Promise((x) => setTimeout(x, 10)) console.log(`2c settled`, q.status, `A`, A.status) }) From e8832898f59ad119d44f80acb184a3225b35fcfe Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 21:43:24 -0600 Subject: [PATCH 091/106] docs: record the maintainer's decisions on replay request errors and start-crashed queries Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/tests/source-health-oracle.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index e8c35c4206..b236e9fd80 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the lazy demand grammar does not check the request error during or after a replay until this is decided. A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 86313c086c..12a7852f9b 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -139,7 +139,7 @@ * lane owns their order). The lazy demand grammar does not yet check the * request error while a source reload's replay is in flight or after one * failed: a demand change during the replay can clear the error before its - * key loads again, which is open. Per-row child Collections are not + * key loads again, which stays open (the maintainer deferred a fix). Per-row child Collections are not * in the tree; an include's node shows its source. Pooled histories do not * restart or clean up: a pooled view ends for good when its Collection is * cleaned up. The ordering check reads the observed query only, not its From 369a00be17ec695c1d09ae1f54d878fcba77108b Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 21 Oct 2026 22:54:36 -0600 Subject: [PATCH 092/106] fix(db): settle the xhigh review of the widened grammar Each fix has a named history or witness that failed first, and a mutant: - When a reload ended early (fail or crash), its open transaction's insert of a kept row became a duplicate, and the commit dropped the new run's data. Those inserts now become updates on the kept rows. - Ending a reload early no longer drops a truncate the adapter called itself; a new grammar step, truncate, generates it. - reload() on a live query that never started now starts it. A query that is not running reloads its failed sources first, then starts. - A plan's request error could clear while a needed key's latest load had failed. The demand controller now tracks failed keys per plan, which replaces the retry bookkeeping; settleDemand no longer clears errors. - A failed replay re-reported its error on each recheck, replacing the node with an identical one. The lazy grammar now checks node identity and announcements on demand changes. - The builder asks the state whether the open transaction replaces every row, instead of reading its queue; the reload's transaction reference is released once used; the scratch probe file is gone. The model also learned production rules the stress runs exposed: a reload aborts a window move that waits for an earlier replay, a failed replay's error covers all the query's demand, and an inner query that crashes as it starts stops the outer start. Co-authored-by: Isaac --- packages/db/mangle-cache.json | 4 +- packages/db/src/collection/index.ts | 2 +- packages/db/src/collection/state.ts | 6 + packages/db/src/collection/subscription.ts | 4 +- packages/db/src/collection/sync.ts | 32 +- .../query/live/collection-config-builder.ts | 22 +- .../src/query/live/collection-subscriber.ts | 41 +-- .../query/live/subset-demand-controller.ts | 63 ++-- .../db/tests/source-health-oracle.test.ts | 287 ++++++++++++++++-- packages/db/tests/zz/probe.test.ts | 143 --------- 10 files changed, 374 insertions(+), 230 deletions(-) delete mode 100644 packages/db/tests/zz/probe.test.ts diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 1f25bb826a..d368787f6d 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -431,5 +431,7 @@ "publishedErrors": "hc", "views": "he", "isDeferredSyncKey": "b6", - "overlayHeldAndActive": "e8" + "overlayHeldAndActive": "e8", + "adapterTruncates": "dp", + "recordOutcome": "gP" } diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 826585f747..b3b14fb4de 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -1312,7 +1312,7 @@ export class CollectionImpl< */ public reload(): Promise { const builder = getBuilderFromConfig(this.config) - return builder ? builder.reload() : this._sync.reload() + return builder ? builder.reload(this) : this._sync.reload() } /** diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index b0713e07c9..a26a8aac87 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -931,6 +931,12 @@ export class CollectionStateManager< ) } + /** Whether the open sync transaction replaces every synced row. */ + openTransactionReplaces(): boolean { + const open = this.pendingSyncedTransactions.at(-1) + return open?.committed === false && open.truncate === true + } + /** Every synced row once every accepted sync transaction applies. */ *acceptedSyncedEntries(): IterableIterator<[TKey, TOutput]> { // A reload's next commit replaces every synced row, so it builds on none. diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index d360745514..754d482ff0 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -673,8 +673,10 @@ export class CollectionSubscription failure: Error, ): void { if (this.truncateReplayState !== replayState) return + // A later check of the same failed attempt reports nothing new. + const first = replayState.completion.isPending() replayState.completion.reject(failure) - this.options.truncateReplayPublication?.fail?.(failure) + if (first) this.options.truncateReplayPublication?.fail?.(failure) // Delegated publication already delivered its rows. Only a private buffer // returns the caller's pagination position to the public snapshot; the // private rows and their sent-key tracking stay together for a retry. diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 2f533a3963..25d7c46b17 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -99,7 +99,9 @@ export class CollectionSyncManager< private syncEntryActive = false // A reload's new sync run has not begun its first transaction yet. // The transaction that carries the reload's truncate, until it commits. - private reloadTruncateTransaction: object | undefined + private readonly adapterTruncates = new WeakSet() + private reloadTruncateTransaction: + (typeof this.state.pendingSyncedTransactions)[number] | undefined private pendingSyncEntryCleanup: | { tasks: Array> @@ -394,6 +396,8 @@ export class CollectionSyncManager< truncate: (options) => { if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() + // The adapter's own truncate stays even if a reload ends early. + this.adapterTruncates.add(pendingTransaction) // Clear all operations from the current transaction pendingTransaction.operations = [] @@ -547,13 +551,27 @@ export class CollectionSyncManager< */ public dropReloadReplacement(): void { this.state.reloadTruncatePending = false - const open = this.state.pendingSyncedTransactions.find( - (transaction) => - transaction === this.reloadTruncateTransaction && - !transaction.committed, + const open = this.reloadTruncateTransaction + this.reloadTruncateTransaction = undefined + if ( + !open || + open.committed || + this.adapterTruncates.has(open) || + !this.state.pendingSyncedTransactions.includes(open) ) - if (!open) return + return open.truncate = false + // The run wrote against no rows; on the kept rows, an insert of a kept + // key is an update. + const kept = this.state.createSyncedKeyLookup() + for (const operation of open.operations) + if ( + operation.originalSyncType === `insert` && + kept(operation.key as TKey) + ) { + operation.type = `update` + operation.originalSyncType = undefined + } this.state.refreshPendingSyncedProjection() } @@ -568,6 +586,8 @@ export class CollectionSyncManager< const syncRunGeneration = this.syncRunGeneration void applied.promise.then( () => { + if (this.reloadTruncateTransaction === transaction) + this.reloadTruncateTransaction = undefined if ( syncRunGeneration === this.syncRunGeneration && this.lifecycle.status === `ready` diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index e47942b84b..ac4b1f7d50 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -134,15 +134,15 @@ export class CollectionConfigBuilder< * Reload each source with an error anywhere in its tree, then this query if * its own node shows an error. A nested live query passes the reload down the * same way. A source's reload keeps this query's status until it commits. + * A query that is not running starts once its sources reloaded, as any + * Collection's reload starts one; `query` is the live-query Collection, + * which a query that never started has not given this builder yet. */ - async reload(): Promise { - const query = this.liveQueryCollection - // A crashed query rejects without touching its sources. One that is not - // running starts, as any Collection's reload does; its preload is what - // lets it start its sources. + async reload( + query: Collection | undefined = this.liveQueryCollection, + ): Promise { + // A crashed query rejects without touching its sources. if (query?.status === `crashed`) return query._sync.reload() - if (query?.status === `idle` || query?.status === `cleaned-up`) - return query.preload() const failed = this.collectionSources .map(({ collection }) => collection) .filter( @@ -151,6 +151,9 @@ export class CollectionConfigBuilder< findProblems(collection.errors).some((node) => node.error !== null), ) await Promise.all(failed.map((collection) => collection.reload())) + // Its preload is what lets a live query start its sources. + if (query?.status === `idle` || query?.status === `cleaned-up`) + return query.preload() // The query reloads itself after its sources when its own node shows an // error (a failed first load or a request error); a reload of it already // in flight settles with that run. @@ -507,7 +510,6 @@ export class CollectionConfigBuilder< const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation || demand.settled) return demand.settled = true - this.clearFailedRequest(planId) try { this.maybeRunGraphFn?.() demand.participant?.resolve() @@ -1128,9 +1130,7 @@ export class CollectionConfigBuilder< begin() // A reload's first transaction replaces every row, so no earlier // row exists for the batch it holds. - const replaces = - config.collection._state.pendingSyncedTransactions.at(-1) - ?.truncate === true + const replaces = config.collection._state.openTransactionReplaces() let lookup: ((key: string | number) => boolean) | undefined const hasSyncedKey = (key: string | number) => { if (replaces) return false diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 78b2f0513b..76d329ddf5 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -221,34 +221,35 @@ export class CollectionSubscriber< this.collectionConfigBuilder.retireDemand(plan.id) return } - // The query no longer needs the data whose load failed. - if (update.failureRetired) - this.collectionConfigBuilder.clearFailedRequest(plan.id) - const generation = this.collectionConfigBuilder.beginDemand( plan.id, update.ready instanceof Promise, ) + // The request error clears once the query needs no key whose latest + // load failed: when a later load of that key succeeds, or when the + // query stops needing it. + const clearIfLoaded = () => { + if (!this.demand.hasFailedKeys(plan.id)) + this.collectionConfigBuilder.clearFailedRequest(plan.id) + } + clearIfLoaded() + const settle = () => { + this.collectionConfigBuilder.settleDemand(plan.id, generation) + clearIfLoaded() + } if (update.ready instanceof Promise) { const settleWith = (ready: Promise) => - void ready.then( - () => this.collectionConfigBuilder.settleDemand(plan.id, generation), - (error) => { - // A truncate replay aborts the acquisition it supersedes. Its - // replacement decides the demand; a real failure rejects it too. - const replacement = subscription.pendingTruncateReplacement - if (replacement) settleWith(replacement) - else - this.collectionConfigBuilder.failDemand( - plan.id, - generation, - error, - ) - }, - ) + void ready.then(settle, (error) => { + // A truncate replay aborts the acquisition it supersedes. Its + // replacement decides the demand; a real failure rejects it too. + const replacement = subscription.pendingTruncateReplacement + if (replacement) settleWith(replacement) + else + this.collectionConfigBuilder.failDemand(plan.id, generation, error) + }) settleWith(update.ready) } else { - this.collectionConfigBuilder.settleDemand(plan.id, generation) + settle() } } diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index bef3a8cf55..f140f2d8b3 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -18,21 +18,20 @@ type DemandSegment = { ready: LoadSubsetRequestResult state: `starting` | `pending` | `settled` | `failed` replaces?: Array - // Keys whose earlier load failed and that this segment loads again. - retries?: Map } type DemandState = { keys: Map segments: Array + // Needed keys whose latest load failed; the plan's request error clears + // once none remains. + failedKeys: Set } export type DemandUpdate = { changed: boolean empty: boolean ready: Promise> | true - // The demand no longer needs any key whose load failed. - failureRetired?: boolean } /** @@ -50,6 +49,11 @@ export class SubsetDemandController { return this.states.keys() } + /** Whether the plan still needs a key whose latest load failed. */ + hasFailedKeys(planId: string): boolean { + return (this.states.get(planId)?.failedKeys.size ?? 0) > 0 + } + setDemand( subscription: CollectionSubscription, plan: LazyDemandPlan, @@ -61,20 +65,6 @@ export class SubsetDemandController { (segment) => segment.state === `failed` && intersects(segment.keys, nextKeys), ) - // Keys whose load failed: in a failed segment, or retried by one still - // loading. - const failedKeys = new Map( - (previous?.segments ?? []).flatMap((segment) => - segment.state === `failed` - ? [...segment.keys] - : segment.state === `settled` - ? [] - : [...(segment.retries ?? [])], - ), - ) - const failureRetired = - failedKeys.size > 0 && - [...failedKeys.keys()].every((key) => !nextKeys.has(key)) if ( previous && equalKeySets(previous.keys, nextKeys) && @@ -98,7 +88,13 @@ export class SubsetDemandController { segment.state === `failed` && equalKeySets(segment.keys, nextKeys), ) - const state: DemandState = { keys: nextKeys, segments } + const state: DemandState = { + keys: nextKeys, + segments, + failedKeys: new Set( + [...(previous?.failedKeys ?? [])].filter((key) => nextKeys.has(key)), + ), + } this.states.set(plan.id, state) for (const segment of retired) releaseSegment(subscription, segment) @@ -126,11 +122,12 @@ export class SubsetDemandController { if (requestedKeys.size > 0) { const segment = createSegment(plan, requestedKeys) if (replace) segment.replaces = [...segments] - const retried = [...failedKeys].filter(([key]) => nextKeys.has(key)) - if (retried.length > 0) segment.retries = new Map(retried) segments.push(segment) - const started = startSegment(subscription, segment, () => - this.warnUnoptimized(plan), + const started = startSegment( + subscription, + segment, + () => this.warnUnoptimized(plan), + (succeeded) => this.recordOutcome(plan.id, segment, succeeded), ) if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } @@ -164,7 +161,22 @@ export class SubsetDemandController { changed: true, empty: false, ready: pending.length > 0 ? Promise.all(pending) : true, - failureRetired, + } + } + + // A load's outcome decides its keys: a success loaded them, and a failure + // of a segment the plan still holds leaves the needed ones missing. + private recordOutcome( + planId: string, + segment: DemandSegment, + succeeded: boolean, + ): void { + const state = this.states.get(planId) + if (!state) return + for (const key of segment.keys.keys()) { + if (succeeded) state.failedKeys.delete(key) + else if (state.segments.includes(segment) && state.keys.has(key)) + state.failedKeys.add(key) } } @@ -277,6 +289,7 @@ function startSegment( subscription: CollectionSubscription, segment: DemandSegment, onUnoptimized: () => void, + onOutcome: (succeeded: boolean) => void, ): boolean { let observed = false const requested = subscription.requestSnapshot({ @@ -294,9 +307,11 @@ function startSegment( void segment.ready.then( () => { segment.state = `settled` + onOutcome(true) }, () => { segment.state = `failed` + onOutcome(false) }, ) } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 12a7852f9b..9dd6de3dc9 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -191,6 +191,9 @@ type Step = // open). The transaction stays open across other steps. | { kind: `write`; source: SourceName; key: RowKey } | { kind: `commit`; source: SourceName } + // `truncate` calls the adapter's truncate() in the open transaction, which + // then replaces every row; it stays the adapter's even if a reload ends. + | { kind: `truncate`; source: SourceName } | { kind: `restart` } | { kind: `reload-query` } // `effect` disposes the Effect over A and creates a new one. @@ -235,8 +238,9 @@ type SourceModel = GateModel & { } // A query's reload() reloads the Collections below it that show an error, // then reloads the query itself once they all settle, unless one failed. +// `start`: the query a cascade ended starts once its reloaded sources settle. type PendingSelfReload = { - target: `query` | `inner` + target: `query` | `inner` | `start` waits: Set failed: boolean } @@ -245,7 +249,7 @@ type RowKey = 1 | 2 | 3 type Rows = Map // A sync transaction the run has begun and not committed. `truncate`: the // transaction is a reload's first, so its commit replaces every row. -type OpenTransaction = { writes: Rows; truncate: boolean } +type OpenTransaction = { writes: Rows; truncate: boolean; adapter?: boolean } type Model = { sources: Record query: GateModel @@ -325,12 +329,18 @@ function startQuery( } function restartEndedQuery(shape: Shape, model: Model): void { model.queryEnded = false - model.selfReloads = [] if (shape === `nested` && model.innerEnded) { model.innerEnded = false model.inner = startQuery(model, [`A`]) } else model.innerEnded = false - model.query = startQuery(model, queryInputs[shape]) + // An inner query that crashes as it starts throws into the outer start, + // which then starts nothing more. + model.query = + shape === `nested` && + model.inner.gate === `crashed` && + model.inner.cause === cleanedUnderQuery + ? { ...model.inner } + : startQuery(model, queryInputs[shape]) deriveQueries(shape, model) deriveEffect(model) model.queryDetached = @@ -556,7 +566,59 @@ function applyModel( // A pooled view has no reload of its own. if (shape === `pooled-nested`) return `ok` if (model.queryEnded) { - restartEndedQuery(shape, model) + // It reloads every Collection below it that shows an error, then + // starts once those reloads succeed; a crashed one rejects, so it does + // not start. + const stopped = shapeSources[shape].filter((name) => { + const source = model.sources[name] + return ( + source.gate === `error` || + source.gate === `crashed` || + source.stopped !== null + ) + }) + if (stopped.length === 0) { + restartEndedQuery(shape, model) + return `ok` + } + // The reloads run together: a crashed or cleaning one rejects, and + // the query then does not start, but the others still reload. + const rejects = stopped.filter( + (name) => + model.sources[name].gate === `crashed` || + model.sources[name].cleaning, + ) + const reloaded = stopped.filter((name) => !rejects.includes(name)) + for (const name of reloaded) { + const source = model.sources[name] + if (source.reloading) continue + source.reloading = true + source.armed = true + source.open = null + } + // A nested query reloads its inner query too, which reloads A, then + // itself. + const waits = new Set(reloaded) + if ( + shape === `nested` && + !model.innerEnded && + model.inner.gate === `error` && + !rejects.includes(`A`) + ) { + model.selfReloads.push({ + target: `inner`, + waits: new Set(reloaded.includes(`A`) ? [`A`] : []), + failed: false, + }) + waits.delete(`A`) + waits.add(`inner`) + } + model.selfReloads.push({ + target: `start`, + waits, + failed: rejects.length > 0, + }) + settleSelfReloads(shape, model) return `ok` } if (model.query.gate === `crashed`) return `throws` @@ -640,6 +702,12 @@ function applyModel( openTransaction(source).writes.set(step.key, ++model.serial) if (step.kind === `commit`) commitOpen(source) if (step.kind === `cancel`) source.open = null + if (step.kind === `truncate`) { + const open = openTransaction(source) + open.writes = new Map() + open.truncate = true + open.adapter = true + } deriveQueries(shape, model) deriveEffect(model) return step.kind === `reload` || step.kind === `reload-cleanup-fails` @@ -665,6 +733,13 @@ function applyModel( case `commit`: commitOpen(source) break + case `truncate`: { + const open = openTransaction(source) + open.writes = new Map() + open.truncate = true + open.adapter = true + break + } case `reload`: // Everything visible stays until the new run commits or fails. The // retired run's open transaction is dropped. @@ -705,7 +780,7 @@ function applyModel( // them. if (source.reloading) { source.armed = false - if (source.open) source.open.truncate = false + if (source.open && !source.open.adapter) source.open.truncate = false } } if (outcome === `throws`) break @@ -744,7 +819,7 @@ function applyModel( // first commit: the rows stay the baseline. if (source.reloading) { source.armed = false - if (source.open) source.open.truncate = false + if (source.open && !source.open.adapter) source.open.truncate = false } if (source.gate !== `crashed`) { source.gate = `crashed` @@ -784,6 +859,10 @@ function settleSelfReloads(shape: Shape, model: Model): void { if (pending.waits.size > 0) continue model.selfReloads.splice(model.selfReloads.indexOf(pending), 1) progressed = true + if (pending.target === `start`) { + if (!pending.failed && model.queryEnded) restartEndedQuery(shape, model) + continue + } const current = model[pending.target] if (pending.failed || current.gate !== `error`) continue // The restarted run fails at once on an input that still failed, and @@ -1371,6 +1450,9 @@ async function runHistory( } else if (step.kind === `commit`) { if (!pre!.open) params.begin() params.commit() + } else if (step.kind === `truncate`) { + if (!pre!.open) params.begin() + params.truncate({ markReady: false }) } else if (step.kind === `cancel`) { const canceled = new AbortController() canceled.abort() @@ -1535,6 +1617,48 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { + 'an inner query that crashes as it starts stops the outer start': [ + { kind: `cleanup-hold`, source: `A` }, + { kind: `cleanup-hold`, source: `B` }, + { kind: `cleanup-settle`, source: `B` }, + { kind: `reload-query` }, + ], + "a query's reload after a cascade reloads a failed source first": [ + on(`ready`, `A`), + on(`fail`, `B`), + { kind: `cleanup-hold`, source: `A` }, + { kind: `cleanup-settle`, source: `A` }, + { kind: `reload-query` }, + on(`ready`, `B`), + on(`ready`, `A`), + ], + "a failed reload keeps the adapter's own truncate": [ + on(`ready`, `A`), + on(`ready`, `B`), + { kind: `write`, source: `A`, key: 2 }, + { kind: `commit`, source: `A` }, + on(`reload`, `A`), + { kind: `truncate`, source: `A` }, + { kind: `write`, source: `A`, key: 3 }, + on(`fail`, `A`), + { kind: `commit`, source: `A` }, + ], + "a failed reload's open write of a kept row applies on the kept rows": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 1 }, + on(`fail`, `A`), + { kind: `commit`, source: `A` }, + ], + 'a crash during a reload still applies its open write of a kept row': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 1 }, + on(`crash`, `A`), + { kind: `commit`, source: `A` }, + ], 'a crash during cleanup lasts until cleanup settles': [ on(`ready`, `A`), on(`ready`, `B`), @@ -1795,8 +1919,11 @@ describe(`source failure and health oracle`, () => { { weight: 1, arbitrary: fc - .constantFrom(`A` as const, `B` as const) - .map((source): Step => ({ kind: `commit`, source })), + .tuple( + fc.constantFrom(`commit` as const, `truncate` as const), + fc.constantFrom(`A` as const, `B` as const), + ) + .map(([kind, source]): Step => ({ kind, source })), }, { weight: 1, arbitrary: fc.constant(restart) }, { weight: 1, arbitrary: fc.constant({ kind: `reload-query` } as const) }, @@ -2121,6 +2248,49 @@ describe(`reload invariant`, () => { }, ) + // A live query that never started has no run yet. Its reload starts it, + // as any Collection's reload starts an idle one, after it reloads a source + // whose first load failed. + it.each([`ready`, `failed`] as const)( + `starts a never-started live query on reload: source %s`, + async (sourceState) => { + let runs = 0 + const source = createCollection({ + id: `health-idle-query-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + gcTime: 0, + startSync: true, + sync: { + sync: (params) => { + if (runs++ === 0 && sourceState === `failed`) { + params.fail(new Error(`first load failed`)) + return + } + params.begin() + params.write({ type: `insert`, value: { id: 1, parentId: 1 } }) + params.commit() + params.markReady() + }, + }, + }) + await source.preload().catch(() => undefined) + const query = createLiveQueryCollection({ + query: (q) => q.from({ row: source }), + startSync: false, + }) + expect(query.status).toBe(`idle`) + try { + await query.reload() + expect(query.status).toBe(`ready`) + expect(query.size).toBe(1) + expect(source.status).toBe(`ready`) + } finally { + await query.cleanup() + await source.cleanup() + } + }, + ) + // A reload whose new run fails keeps the rows, so they stay the baseline // that a diffing adapter, such as query-db, reads for its next write. // A transaction the new run opened before it failed is no longer the @@ -3287,6 +3457,10 @@ type DemandModel = { offset: number // The window whose load last succeeded; the query shows it. shown: number + // A source reload's replay is pending; a window move made now waits for + // it, and a later reload aborts that move (setWindow() rejects). + replayPending: boolean + moveWaitsForReplay: boolean source: { failure: string | null; stopped: string | null; crashed: boolean } serial: number } @@ -3310,11 +3484,15 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { if (model.gate === `crashed`) return switch (step) { case `window`: - if (model.gate === `ready`) model.offset += 2 + if (model.gate !== `ready`) return + model.offset += 2 + if (model.replayPending) model.moveWaitsForReplay = true return case `settle-ok`: if (model.pending === 0) return model.pending = 0 + model.replayPending = false + model.moveWaitsForReplay = false if (model.gate === `loading`) model.gate = `ready` if (model.requestError && model.laterLoad) model.requestError = null model.shown = model.offset @@ -3322,6 +3500,8 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { case `settle-fail`: if (model.pending === 0) return model.pending = 0 + model.replayPending = false + model.moveWaitsForReplay = false if (model.gate === `loading`) { model.gate = `error` return @@ -3338,6 +3518,11 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { if (model.source.crashed) return model.source.failure = null model.source.stopped = null + if (model.moveWaitsForReplay) { + model.offset = model.shown + model.moveWaitsForReplay = false + } + model.replayPending = true return case `report`: if (model.source.stopped) return @@ -3367,6 +3552,8 @@ async function runDemandHistory(steps: ReadonlyArray) { pending: 0, offset: 0, shown: 0, + replayPending: false, + moveWaitsForReplay: false, source: { failure: null, stopped: null, crashed: false }, serial: 0, } @@ -3402,7 +3589,6 @@ async function runDemandHistory(steps: ReadonlyArray) { }, }, }) - void params const query = createLiveQueryCollection((q) => q .from({ row: source }) @@ -3442,8 +3628,6 @@ async function runDemandHistory(steps: ReadonlyArray) { source._lifecycle.crash(new Error(`source crashed`)) } else { try { - const current = source._lifecycle - void current if (step === `report`) params.reportError(new Error(`source problem`)) else if (step === `healthy`) params.reportHealthy() else params.fail(new Error(`source gave up`)) @@ -3502,6 +3686,19 @@ describe(`on-demand demand grammar`, () => { 'a failed load after readiness is a request error, and a later load clears it': [`settle-ok`, `window`, `settle-fail`, `window`, `settle-ok`], 'a failed first load fails the query': [`settle-fail`], + 'a reload aborts a window move that waits for an earlier replay': [ + `settle-ok`, + `reload`, + `window`, + `reload`, + `settle-ok`, + ], + 'a window move with its own load joins the next replay': [ + `settle-ok`, + `window`, + `reload`, + `settle-ok`, + ], "a failed source reload's replay is a request error": [ `settle-ok`, `window`, @@ -3586,8 +3783,10 @@ type LazyModel = { // A source reload's replay is in flight: every load the query starts // joins it, inside its publication barrier. replayActive: boolean - // Needed keys a failed replay could not load again. - replayFailed: Set + // A failed replay keeps the query's publication private for all its + // demand, so its error stays until a replay succeeds or the query has no + // demand left. + replayFailed: boolean pending: Array<{ keys: Set; replay: boolean }> source: { failure: string | null; stopped: string | null; crashed: boolean } } @@ -3631,7 +3830,7 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { case `unparent`: model.parents.delete(step.key) model.failed.delete(step.key) - model.replayFailed.delete(step.key) + if (model.parents.size === 0) model.replayFailed = false return case `settle-ok`: for (const { keys, replay } of model.pending) { @@ -3639,7 +3838,7 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.loaded.add(key) model.failed.delete(key) } - if (replay) model.replayFailed.clear() + if (replay) model.replayFailed = false } model.pending = [] model.replayActive = false @@ -3654,7 +3853,7 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { // A replay fails the query's data only for demand it still needs. for (const key of keys) { if (!model.parents.has(key)) continue - if (replay) model.replayFailed.add(key) + if (replay) model.replayFailed = true else model.failed.add(key) } } @@ -3696,12 +3895,13 @@ async function runLazyHistory(steps: ReadonlyArray) { loaded: new Set(), failed: new Set(), replayActive: false, - replayFailed: new Set(), + replayFailed: false, pending: [], source: { failure: null, stopped: null, crashed: false }, } const loads: Array>> = [] const requests: Array> = [] + let failures = 0 let parentParams!: SyncParams let childParams!: SyncParams const parents = createCollection({ @@ -3772,8 +3972,18 @@ async function runLazyHistory(steps: ReadonlyArray) { void query.preload().catch(() => undefined) await flushPromises() report() + let announcements = 0 + query.on(`errors:change`, () => announcements++) for (const [index, step] of steps.entries()) { const label = `step ${index} ${JSON.stringify(step)}` + // A demand change that leaves the projected tree as it was keeps the + // tree's identity and announces nothing. + const treeBefore = query.errors + const factsBefore = JSON.stringify(project(treeBefore)) + const announcedBefore = announcements + // Only a demand change can leave every fact as it was; a reload, a + // settled load, or a source call can change a fact and change it back. + const demandOnly = step.kind === `parent` || step.kind === `unparent` applyLazyModel(model, step) if (step.kind === `parent` || step.kind === `unparent`) { parentParams.begin() @@ -3789,8 +3999,9 @@ async function runLazyHistory(steps: ReadonlyArray) { if (round > 0) report() } } else if (step.kind === `settle-fail`) { + // Each failure has its own message, so two problems never look alike. for (const load of loads.splice(0)) - load.reject(new Error(`child load failed`)) + load.reject(new Error(`child load ${++failures} failed`)) } else if (step.kind === `reload`) { void children.reload().catch(() => undefined) } else if (step.kind === `crash`) { @@ -3809,16 +4020,26 @@ async function runLazyHistory(steps: ReadonlyArray) { deriveLazyGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) + expect(query.errors, `${label}: stable read`).toBe(query.errors) + if (demandOnly && JSON.stringify(project(query.errors)) === factsBefore) { + expect(query.errors, `${label}: unchanged tree keeps identity`).toBe( + treeBefore, + ) + expect( + announcements, + `${label}: unchanged tree announces nothing`, + ).toBe(announcedBefore) + } // Open: a demand change while a source reload's replay is in flight // can clear a request error before its key loads again (see the // coverage map). Until that is settled, the check waits for the // replay to succeed; it still checks the error a failed replay shows. const replayOpen = - (model.replayActive || model.replayFailed.size > 0) && + (model.replayActive || model.replayFailed) && step.kind !== `settle-fail` if (model.gate === `ready` && !replayOpen) { expect(query.errors.error !== null, `${label}: request error`).toBe( - model.failed.size > 0 || model.replayFailed.size > 0, + model.failed.size > 0 || model.replayFailed, ) } // After a failed replay the query keeps its last complete snapshot @@ -3826,7 +4047,7 @@ async function runLazyHistory(steps: ReadonlyArray) { if ( model.gate === `ready` && model.pending.length === 0 && - model.replayFailed.size === 0 + !model.replayFailed ) { const rows = query.toArray.map( (row: { parent: Row; child?: Row }) => @@ -3898,6 +4119,26 @@ describe(`lazy join demand grammar`, () => { { kind: `reload` }, { kind: `settle-fail` }, ], + "a failed replay's error is not reported again when another demand retires": + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 2 }, + ], + 'a failed replacement keeps its error while a still-needed key is missing': + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `parent`, key: 3 }, + { kind: `settle-ok` }, + { kind: `unparent`, key: 1 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 3 }, + ], } for (const [name, steps] of Object.entries(named)) it(name, () => runLazyHistory(steps)) diff --git a/packages/db/tests/zz/probe.test.ts b/packages/db/tests/zz/probe.test.ts deleted file mode 100644 index a4bfa88150..0000000000 --- a/packages/db/tests/zz/probe.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { it } from 'vitest' -import { createCollection, createLiveQueryCollection } from '../../src/index' -import { createEffect } from '../../src/query/effect' -import { createDeferred } from '../../src/deferred' - -const mk = ( - name: string, - holds: Record, - params: Record, -) => - createCollection({ - id: `zz-${name}-${Math.random()}`, - getKey: (r) => r.id, - gcTime: 0, - sync: { - sync: (p) => { - params[name] = p - p.begin() - p.write({ type: `insert`, value: { id: 1 } }) - p.commit() - p.markReady() - return () => holds[name]?.promise - }, - }, - }) -it(`1: crash during and after cleanup`, async () => { - const holds: any = {}, - params: any = {} - const A = mk(`A`, holds, params) - await A.preload() - holds.A = createDeferred() - const c = A.cleanup() - A._lifecycle.crash(new Error(`crash during cleanup`)) - console.log(`1 during`, A.status, A.errors.error?.message) - holds.A.resolve() - await c.catch((e) => console.log(`1 cleanup rejected`, String(e))) - console.log(`1 settled`, A.status, A.errors.error?.message ?? null) - try { - A._lifecycle.crash(new Error(`crash after cleanup`)) - } catch (e) { - console.log(`1 crash after threw`, String(e)) - } - console.log(`1 after`, A.status, A.errors.error?.message ?? null) - let r = `?` - await A.reload().then( - () => (r = `resolved`), - (e) => (r = `rejected ${e.constructor.name}`), - ) - console.log(`1 reload`, r, A.status) - holds.A = undefined - await A.cleanup() - await A.preload().then( - () => console.log(`1 fresh`, A.status, A.size), - (e) => console.log(`1 fresh rejected`, String(e)), - ) -}) -it(`2: query reload after cascade ended it`, async () => { - const holds: any = {}, - params: any = {} - const A = mk(`A`, holds, params) - const q = createLiveQueryCollection((qq) => qq.from({ a: A })) - await q.preload() - await A.cleanup({ dependents: true }) - console.log(`2 ended`, q.status, A.status) - let r = `?` - const p = q.reload().then( - () => (r = `resolved`), - (e) => (r = `rejected ${e.constructor.name}`), - ) - await new Promise((x) => setTimeout(x, 10)) - await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) - console.log(`2 after query reload`, r, q.status, q.size, `A`, A.status) -}) -it(`3: effect created during pending cleanup`, async () => { - const holds: any = {}, - params: any = {} - const A = mk(`A`, holds, params) - await A.preload() - holds.A = createDeferred() - const c = A.cleanup({ dependents: true }) - const errs: Array = [] - let threw = `none` - let e: any - try { - e = createEffect({ - query: (qq) => qq.from({ row: A }), - onBatch: () => {}, - onSourceError: (x) => errs.push(String(x)), - }) - } catch (x) { - threw = String(x) - } - await new Promise((x) => setTimeout(x, 0)) - console.log(`3 during`, threw, e?.disposed, errs, A.status) - holds.A.resolve() - await c.catch(() => {}) - await new Promise((x) => setTimeout(x, 0)) - console.log(`3 settled`, e?.disposed, errs, A.status) - await e?.dispose() -}) -it(`2b: query preload after cascade ended it`, async () => { - const holds: any = {}, - params: any = {} - const A = mk(`A`, holds, params) - const q = createLiveQueryCollection((qq) => qq.from({ a: A })) - await q.preload() - await A.cleanup({ dependents: true }) - const p = q.preload() - await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) - console.log(`2b after query preload`, q.status, q.size, `A`, A.status) - const sub = q.subscribeChanges(() => {}) - await new Promise((x) => setTimeout(x, 20)) - console.log(`2b after subscribe`, q.status, q.size, `A`, A.status) - sub.unsubscribe() -}) -it(`2c: query reload while its source is cleaning`, async () => { - const holds: any = {}, - params: any = {} - const A = mk(`A`, holds, params) - const q = createLiveQueryCollection((qq) => qq.from({ a: A })) - await q.preload() - holds.A = createDeferred() - const c = A.cleanup({ dependents: true }) - await new Promise((x) => setTimeout(x, 0)) - let r = `?` - const p = q.reload().then( - () => (r = `resolved`), - (e) => (r = `rejected ${e.constructor.name} ${e.message}`), - ) - await Promise.race([p, new Promise((x) => setTimeout(x, 50))]) - console.log( - `2c during`, - r, - q.status, - JSON.stringify(q.errors.error?.message ?? null), - `A`, - A.status, - ) - holds.A.resolve() - await c.catch(() => {}) - await new Promise((x) => setTimeout(x, 10)) - console.log(`2c settled`, q.status, `A`, A.status) -}) From 0c2287b845ca9cc5ffcae0ff708234aa3d1f3db7 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 10:03:35 -0600 Subject: [PATCH 093/106] fix(db): close xhigh review findings on reload replay, readiness, and cleanup Lazy demand and source reloads: - A query's first load waits for a source reload's whole replay; it no longer turns ready while the replay still loads a needed key. - A replay success clears only the request errors of demands it loaded again. A load that threw holds no demand, so its error stays. - A thrown lazy-demand load during the query's start still fails the start and releases its sources; during a source commit it fails the first load without escaping into that commit. - Only lazy demand (a caller with its own abort signal) follows a reload's replay through its result; the ordered loader follows replays itself. Reload and cleanup: - A failed reload's open insert of a kept row replaces the row instead of merging into it. - Effect onSourceError JSDoc lists subset load and release failures. Oracle (source-health): - Main grammar: writes that omit the tag field, reload-cleanup-held and retired-cleanup-rejects steps (a late retired-cleanup rejection crashes the Collection, also after a cleanup and a fresh start, as decided). - Lazy grammar: adapters that ignore aborts, obsolete loads that still succeed, thrown loads by origin, replay settled by release, replay failure judged at settlement. - On-demand grammar: a window move after a failed replay waits for the next successful replay. - The deferred demand-change-during-replay class now also skips the identity and announcement check. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 6 +- packages/db/src/collection/lifecycle.ts | 4 +- packages/db/src/collection/state.ts | 11 +- packages/db/src/collection/subscription.ts | 62 +- packages/db/src/collection/sync.ts | 48 +- packages/db/src/query/effect.ts | 11 +- packages/db/src/query/live/ARCHITECTURE.md | 7 +- .../query/live/collection-config-builder.ts | 19 +- .../src/query/live/collection-subscriber.ts | 11 +- .../query/live/subset-demand-controller.ts | 60 +- .../db/tests/source-health-oracle.test.ts | 618 +++++++++++++++--- 12 files changed, 690 insertions(+), 169 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index b236e9fd80..10959da071 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index d368787f6d..37f553b68a 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -425,13 +425,13 @@ "failureReasonFromReload": "g5", "endReload": "g9", "loadOrder": "ha", - "reloadTruncateTransaction": "gY", "cancelSyncTransaction": "g7", "startedBeforeFailure": "hb", "publishedErrors": "hc", "views": "he", "isDeferredSyncKey": "b6", "overlayHeldAndActive": "e8", - "adapterTruncates": "dp", - "recordOutcome": "gP" + "recordOutcome": "gP", + "reloadRetiredAcquisitions": "dp", + "resultAcrossReload": "gR" } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 46a4772fd2..690d2bc17a 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -352,7 +352,7 @@ export class CollectionLifecycleManager< * The reload's first commit replaced the rows: its problems clear, and a * failed first load becomes ready. */ - public completeReload(): void { + public completeReload(beforeEffects?: () => void): void { const run = this.reloadRun if (!run) return this.reloadRun = undefined @@ -366,7 +366,7 @@ export class CollectionLifecycleManager< this.statusError = null this.recovering = true try { - this.markReady() + this.markReady(beforeEffects) } catch (error) { // A ready callback that throws reaches the adapter's markReady(); the // reload succeeded once the status is ready. diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index a26a8aac87..d4719f6bbc 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -58,6 +58,10 @@ interface PendingSyncedTransaction< layoutChanged: boolean operations: Array> truncate?: boolean + // Who made the transaction replace every row: a reload's first + // transaction, or the adapter's own truncate(). A reload that ends early + // drops only its own. + truncateOwner?: `reload` | `adapter` truncateMarkReady?: boolean rowMetadataWrites: Map /** The last explicit write per key, after `position` operations. */ @@ -1492,8 +1496,13 @@ export class CollectionStateManager< // A truncate that makes the Collection ready publishes its batch after // the status reads ready and before status listeners and ready // callbacks run, so their writes follow the batch they describe. + // During a reload, that is the reload's completion. if (truncateMarksReady && this.lifecycle.status !== `ready`) - capture(() => this.lifecycle.markReady(emit)) + capture(() => + this.lifecycle.reloading + ? this.lifecycle.completeReload(emit) + : this.lifecycle.markReady(emit), + ) if (!emitted) emit() if (this.syncRunGeneration === syncRunGeneration) { diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 754d482ff0..310e410a66 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -119,6 +119,8 @@ type SubsetDemand = { acquisition: SubsetAcquisitionRecord acquisitionState: `starting` | `active` | `detached` initialResult?: Deferred + // The result its caller holds while it follows the current acquisition. + caller?: Deferred } type TruncateReplayAttempt = { @@ -332,6 +334,18 @@ export class CollectionSubscription if (!keepWaitingCallers || demand.acquisitionState === `starting`) { demand.initialResult?.reject(new LoadSubsetOperationAbortedError()) } + // A reload keeps the waiting caller: the new run's replay of this + // demand settles it, and the retired acquisition decides nothing. + const caller = demand.caller + if ( + keepWaitingCallers && + demand.acquisitionState !== `starting` && + caller?.isPending() + ) { + this.reloadRetiredAcquisitions.add(demand.acquisition) + demand.initialResult ??= createDeferred() + void demand.initialResult.promise.then(caller.resolve, caller.reject) + } cancelAcquisition(demand.acquisition) if (demand.acquisitionState === `starting`) { const index = this.subsetDemands.indexOf(demand) @@ -511,11 +525,14 @@ export class CollectionSubscription replayState.failed = true } } - if (demand.initialResult) { - void replayState.completion.promise.then( - demand.initialResult.resolve, - demand.initialResult.reject, - ) + const waiting = demand.initialResult + if (waiting) { + // A replay discarded by a later reload leaves the caller waiting for + // that reload's replay; cleanup rejects waiting callers itself. + void replayState.completion.promise.then(waiting.resolve, (error) => { + if (!(error instanceof LoadSubsetOperationAbortedError)) + waiting.reject(error) + }) } // Sequential handoff: retire the old acquisition lease while retaining its @@ -1062,6 +1079,33 @@ export class CollectionSubscription } /** Start and retain the first acquisition for one logical subset demand. */ + /** + * A demand's result for its caller. A reload retires the sync run and + * aborts its acquisitions; a demand that is still active is then served by + * the new run's replay of it, so the abort is not this demand's outcome. + */ + private readonly reloadRetiredAcquisitions = new WeakSet() + + private resultAcrossReload( + demand: SubsetDemand, + result: LoadSubsetRequestResult, + ): LoadSubsetRequestResult { + if (!(result instanceof Promise)) return result + const acquisition = demand.acquisition + const caller = createDeferred() + demand.caller = caller + const retired = () => this.reloadRetiredAcquisitions.has(acquisition) + void result.then( + () => { + if (!retired()) caller.resolve() + }, + (error: unknown) => { + if (!retired()) caller.reject(error) + }, + ) + return caller.promise + } + private startSubsetDemand(requestOptions: LoadSubsetOptions): { demand: SubsetDemand result: LoadSubsetRequestResult @@ -1281,9 +1325,13 @@ export class CollectionSubscription } = this.startSubsetDemand(loadOptions) if (!this.isDemandActive(demand)) return false - // Report the result synchronously, including a wait for an unavailable loader. + // Report the result synchronously, including a wait for an unavailable + // loader. A caller that aborts its own request follows a reload's replay + // of it; the ordered loader follows replays itself. opts?.onLoadSubsetResult?.( - syncResult, + started && opts.signal + ? this.resultAcrossReload(demand, syncResult) + : syncResult, demand.acquisition.options, (primaryFailure) => this.releaseDemand(demand, primaryFailure), ) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 25d7c46b17..cae1c902e1 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -93,15 +93,12 @@ export class CollectionSyncManager< private syncStartRequested = false private deferredLoadSubsets: Array = [] // Fences callbacks retained across reentrant sync entry and cleanup. This - // changes at both boundaries; syncRunGeneration changes only at cleanup. + // changes at both boundaries; syncRunGeneration changes at cleanup and when + // a reload retires a run. private syncCallbackEpoch = 0 private syncRunGeneration = 0 private syncEntryActive = false // A reload's new sync run has not begun its first transaction yet. - // The transaction that carries the reload's truncate, until it commits. - private readonly adapterTruncates = new WeakSet() - private reloadTruncateTransaction: - (typeof this.state.pendingSyncedTransactions)[number] | undefined private pendingSyncEntryCleanup: | { tasks: Array> @@ -397,7 +394,7 @@ export class CollectionSyncManager< if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() // The adapter's own truncate stays even if a reload ends early. - this.adapterTruncates.add(pendingTransaction) + pendingTransaction.truncateOwner = `adapter` // Clear all operations from the current transaction pendingTransaction.operations = [] @@ -551,27 +548,23 @@ export class CollectionSyncManager< */ public dropReloadReplacement(): void { this.state.reloadTruncatePending = false - const open = this.reloadTruncateTransaction - this.reloadTruncateTransaction = undefined - if ( - !open || - open.committed || - this.adapterTruncates.has(open) || - !this.state.pendingSyncedTransactions.includes(open) + const open = this.state.pendingSyncedTransactions.find( + (transaction) => + !transaction.committed && transaction.truncateOwner === `reload`, ) - return + if (!open) return open.truncate = false // The run wrote against no rows; on the kept rows, an insert of a kept - // key is an update. + // key replaces that row. const kept = this.state.createSyncedKeyLookup() - for (const operation of open.operations) - if ( - operation.originalSyncType === `insert` && - kept(operation.key as TKey) - ) { - operation.type = `update` - operation.originalSyncType = undefined - } + open.operations = open.operations.flatMap((operation) => + operation.originalSyncType === `insert` && kept(operation.key as TKey) + ? [ + { ...operation, type: `delete`, originalSyncType: undefined }, + operation, + ] + : [operation], + ) this.state.refreshPendingSyncedProjection() } @@ -579,15 +572,13 @@ export class CollectionSyncManager< private markReloadTruncate(applied: { promise: Promise }): void { this.state.reloadTruncatePending = false const transaction = this.state.pendingSyncedTransactions.at(-1)! - this.reloadTruncateTransaction = transaction + transaction.truncateOwner = `reload` transaction.truncate = true transaction.truncateMarkReady = false this.state.refreshPendingSyncedProjection() const syncRunGeneration = this.syncRunGeneration void applied.promise.then( () => { - if (this.reloadTruncateTransaction === transaction) - this.reloadTruncateTransaction = undefined if ( syncRunGeneration === this.syncRunGeneration && this.lifecycle.status === `ready` @@ -612,10 +603,7 @@ export class CollectionSyncManager< reason?: Error, ): void { this.state.cancelPendingSyncedTransaction(transaction, reason) - if ( - transaction === this.reloadTruncateTransaction && - this.lifecycle.reloading - ) + if (transaction.truncateOwner === `reload` && this.lifecycle.reloading) this.state.reloadTruncatePending = true } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index d53b0a5719..005d33aff9 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -135,10 +135,13 @@ export interface EffectConfig< onError?: (error: Error, event: DeltaEvent) => void /** - * Called when a source fails its first load before the effect's - * initial-query readiness, crashes, or is cleaned up. The effect is - * automatically disposed after this callback fires. If not provided, the - * error is logged to console.error. + * Called when a source crashes, fails its first load before the effect's + * initial-query readiness, or is cleaned up while the effect reads it, and + * when a subset load the effect requests from a source, or its release, + * fails, before or after that readiness. A cascade cleanup that ends the + * effect does not call it. + * The effect is automatically disposed after this callback fires. If not + * provided, the error is logged to console.error. */ onSourceError?: (error: Error) => void diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 26e14970bf..66a2f5e311 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1698,7 +1698,12 @@ every row before the top-K (a recorded limit). load or a root Collection's load that fails after initial-query readiness is an `error` on the query's own node. A lazy demand's error clears when that demand settles or is released; a root Collection's error clears when - a later load of that Collection settles. Query failures and contract + a later load of that Collection settles. A source reload's replay loads + the query's demand again as one unit: the query's first load waits for + the whole replay, the replay fails only for a demand the query still + holds when it settles, and its success clears only the errors of demands + it loaded again. A load that threw holds no demand, so a replay does not + load it again and its error stays. Query failures and contract violations, such as a graph failure or a Collection cleaned up under the query, crash the query. An Effect follows the same rules, with its initial-query readiness at its settled initial loads: a crashed source, diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ac4b1f7d50..138252c66f 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -124,6 +124,8 @@ export class CollectionConfigBuilder< // Error state tracking private fatalQueryError = false + // The sync function is running: a failure there fails the query's start. + private starting = false private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions @@ -473,6 +475,11 @@ export class CollectionConfigBuilder< } } + /** The query's sync function is running. */ + isStarting(): boolean { + return this.starting + } + isLazySource(sourceId: string): boolean { return this.lazySources.has(sourceId) } @@ -886,6 +893,7 @@ export class CollectionConfigBuilder< } } + this.starting = true try { // Extend the pipeline such that it applies the incoming changes to the collection const fullSyncState = this.extendPipelineWithChangeProcessing( @@ -922,6 +930,8 @@ export class CollectionConfigBuilder< // Preserve the setup failure. It is the error the caller can act on. } throw error + } finally { + this.starting = false } return teardown @@ -1321,9 +1331,16 @@ export class CollectionConfigBuilder< // 2. All source collections are ready // 3. Every active route demand has settled // 4. The live query collection is not loading subset data + // 5. No source reload's replay is still loading the demand again // This prevents marking the live query ready before its data is processed // (fixes issue where useLiveQuery returns isReady=true with empty data) - if (subscribedToAll && allReady && allDemandsSettled && !isLoading) { + if ( + subscribedToAll && + allReady && + allDemandsSettled && + !isLoading && + !this.hasPendingSourceRecovery() + ) { markReady() } } diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 76d329ddf5..a5ad23b27e 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -206,13 +206,12 @@ export class CollectionSubscriber< // Convert that synchronous form to the same query-local fatal demand // state as a rejected load, without letting it escape the source commit. // Preserve unrelated graph/programming errors as throws. + // During the query's start, the throw fails the start, which releases + // the sources it subscribed to. if (!Object.is(subscription.lastError, error)) throw error - const isInitialSync = - this.collectionConfigBuilder.liveQueryCollection?._lifecycle - .inFirstLoad === true const generation = this.collectionConfigBuilder.beginDemand(plan.id) this.collectionConfigBuilder.failDemand(plan.id, generation, error) - if (isInitialSync) throw error + if (this.collectionConfigBuilder.isStarting()) throw error return } if (!update.changed) return @@ -439,10 +438,10 @@ export class CollectionSubscriber< return } this.orderedLoader?.settleFullSourceReplay() - // The replay loaded every demand of this subscription again. + // The replay loaded every demand this subscription holds again. this.collectionConfigBuilder.settleReplayedRequests( this.sourceId, - this.demand.planIds(), + this.demand.settleReplay(), ) this.collectionConfigBuilder.scheduleGraphRunIfSyncRunCurrent( syncRunGeneration, diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index f140f2d8b3..8acb2c1799 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -17,6 +17,9 @@ type DemandSegment = { abortController: AbortController ready: LoadSubsetRequestResult state: `starting` | `pending` | `settled` | `failed` + // The load threw, so the subscription does not hold it, and a source + // reload's replay does not load it again. + thrown?: true replaces?: Array } @@ -44,9 +47,19 @@ export class SubsetDemandController { private readonly warnedPlans = new Set() private valueIdentity = createValueIdentity() - /** The plans this controller holds demand for. */ - planIds(): IterableIterator { - return this.states.keys() + /** + * A source reload's replay loaded every segment the subscription holds + * again. Returns the plans that no longer miss a key. + */ + settleReplay(): Array { + const loaded: Array = [] + for (const [planId, state] of this.states) { + for (const segment of state.segments) + if (segment.state === `failed` && !segment.thrown) + for (const key of segment.keys.keys()) state.failedKeys.delete(key) + if (state.failedKeys.size === 0) loaded.push(planId) + } + return loaded } /** Whether the plan still needs a key whose latest load failed. */ @@ -119,16 +132,31 @@ export class SubsetDemandController { const replace = retryReplacement || (removed && (added.size > 0 || segments.length > 1)) const requestedKeys = replace ? nextKeys : added + // A replacement alone decides the demand: the segments it supersedes are + // released, and aborted, once it settles. + let replacement: DemandSegment | undefined if (requestedKeys.size > 0) { const segment = createSegment(plan, requestedKeys) - if (replace) segment.replaces = [...segments] + if (replace) { + segment.replaces = [...segments] + replacement = segment + } segments.push(segment) - const started = startSegment( - subscription, - segment, - () => this.warnUnoptimized(plan), - (succeeded) => this.recordOutcome(plan.id, segment, succeeded), - ) + let started: boolean + try { + started = startSegment( + subscription, + segment, + () => this.warnUnoptimized(plan), + (succeeded) => this.recordOutcome(plan.id, segment, succeeded), + ) + } catch (error) { + // A load that throws failed its keys, as one that rejects does. + segment.state = `failed` + segment.thrown = true + this.recordOutcome(plan.id, segment, false) + throw error + } if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } } @@ -150,7 +178,7 @@ export class SubsetDemandController { } } - const pending = state.segments + const pending = (replacement ? [replacement] : state.segments) .filter( (segment) => segment.state === `pending` && intersects(segment.keys, nextKeys), @@ -164,19 +192,19 @@ export class SubsetDemandController { } } - // A load's outcome decides its keys: a success loaded them, and a failure - // of a segment the plan still holds leaves the needed ones missing. + // The outcome of a segment the plan still holds decides its keys: a + // success loaded them, and a failure leaves the needed ones missing. A + // released segment's late outcome decides nothing. private recordOutcome( planId: string, segment: DemandSegment, succeeded: boolean, ): void { const state = this.states.get(planId) - if (!state) return + if (!state?.segments.includes(segment)) return for (const key of segment.keys.keys()) { if (succeeded) state.failedKeys.delete(key) - else if (state.segments.includes(segment) && state.keys.has(key)) - state.failedKeys.add(key) + else if (state.keys.has(key)) state.failedKeys.add(key) } } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 9dd6de3dc9..7d88d8a3c0 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -77,7 +77,12 @@ * reload of a crashed Collection rejects. `reload-cleanup-fails` reloads while * the retired run's cleanup rejects: that breaks the adapter contract, so the * Collection crashes and the reload ends with it, unless a reload is already in - * flight and nothing is retired. `cancel` commits the open transaction (or a + * flight and nothing is retired. `reload-cleanup-held` reloads while the + * retired run's cleanup stays pending, and `retired-cleanup-rejects` later + * rejects it: that crashes the Collection too, also after a cleanup and a + * fresh start, because the adapter broke its contract for that Collection + * (maintainer decision). + * `cancel` commits the open transaction (or a * new one) with an aborted signal: its writes are gone, and a reload's * replacement waits for the run's next transaction. The driver writes like * a diffing adapter: an insert for a key missing from the baseline and an @@ -183,17 +188,19 @@ type SourceStepKind = | `crash` | `reload` | `reload-cleanup-fails` + | `reload-cleanup-held` + | `retired-cleanup-rejects` | `cancel` type Step = | { kind: SourceStepKind; source: SourceName } // `write` adds a row to the sync run's open transaction, beginning one if // none is open; `commit` commits it (an empty transaction when none is // open). The transaction stays open across other steps. - | { kind: `write`; source: SourceName; key: RowKey } + | { kind: `write`; source: SourceName; key: RowKey; untagged?: boolean } | { kind: `commit`; source: SourceName } // `truncate` calls the adapter's truncate() in the open transaction, which // then replaces every row; it stays the adapter's even if a reload ends. - | { kind: `truncate`; source: SourceName } + | { kind: `truncate`; source: SourceName; markReady?: boolean } | { kind: `restart` } | { kind: `reload-query` } // `effect` disposes the Effect over A and creates a new one. @@ -226,9 +233,12 @@ type GateModel = { gate: Gate; cause: string | null; reloading: boolean } // `cleaning`: cleanup started and the adapter's cleanup has not settled. // `retired`: cleanup ended the sync run and no new one has started, so the // adapter's calls do nothing. +// `retiredCleanup`: a reload retired a run whose cleanup is still pending. +// Cleanup and a fresh start keep it pending. type SourceModel = GateModel & { cleaning: boolean retired: boolean + retiredCleanup: boolean failure: Problem stopped: Problem failureFromReload: boolean @@ -245,11 +255,18 @@ type PendingSelfReload = { failed: boolean } type RowKey = 1 | 2 | 3 -// Synced rows by key, each with the tag of the write that produced it. +// Synced rows by key, each with the tag of the write that produced it. A +// write without a tag field gives an inserted row tag 0, and an update keeps +// the tag the row had: a sync update merges into the row. type Rows = Map // A sync transaction the run has begun and not committed. `truncate`: the // transaction is a reload's first, so its commit replaces every row. -type OpenTransaction = { writes: Rows; truncate: boolean; adapter?: boolean } +type OpenTransaction = { + writes: Rows + truncate: boolean + adapter?: boolean + marksReady?: boolean +} type Model = { sources: Record query: GateModel @@ -358,6 +375,7 @@ function restartSource(source: SourceModel): void { failureFromReload: false, cleaning: false, retired: false, + retiredCleanup: source.retiredCleanup, rows: new Map(), open: null, armed: false, @@ -416,6 +434,45 @@ function expectedBaseline(source: SourceModel): Rows { return new Map([...source.rows, ...(source.open?.writes ?? [])]) } +function isReload(kind: Step[`kind`]): boolean { + return ( + kind === `reload` || + kind === `reload-cleanup-fails` || + kind === `reload-cleanup-held` + ) +} + +// From any active status, for good. A reload ends with it, before its first +// commit: the rows stay the baseline. +function crashSource(source: SourceModel, cause: string): void { + if (source.reloading) { + source.armed = false + if (source.open && !source.open.adapter) source.open.truncate = false + } + if (source.gate !== `crashed`) { + source.gate = `crashed` + source.cause = cause + source.failure = null + source.stopped = null + source.reloading = false + } +} + +// The adapter writes an update for a key in its baseline, and an insert for +// any other key. +function writeRow( + model: Model, + source: SourceModel, + step: Extract, +): void { + const tag = ++model.serial + const previous = expectedBaseline(source).get(step.key) + openTransaction(source).writes.set( + step.key, + step.untagged ? (previous ?? 0) : tag, + ) +} + // The run begins a transaction. A reload's run replaces every row with its // first transaction. function openTransaction(source: SourceModel): OpenTransaction { @@ -439,14 +496,26 @@ function completeReload(source: SourceModel): void { } // Commit the open transaction: a reload's first one replaces every row. -function commitOpen(source: SourceModel): void { - const { writes, truncate } = openTransaction(source) +// Returns the outcome of a truncate that marks the Collection ready, which +// is a markReady() at its commit: a reload completes, a first load becomes +// ready, and after a failed first load or fail() it breaks the adapter +// contract, so the commit throws after the rows apply. +function commitOpen(source: SourceModel): `ok` | `throws` | `bad-transition` { + const { writes, truncate, marksReady } = openTransaction(source) source.rows = truncate ? new Map(writes) : new Map([...source.rows, ...writes]) source.open = null - if (truncate && source.reloading && source.gate === `ready`) + if (truncate && source.reloading && source.gate === `ready`) { completeReload(source) + return `ok` + } + if (!truncate || !marksReady || source.gate === `crashed`) return `ok` + if (source.reloading) completeReload(source) + else if (source.gate === `error`) return `bad-transition` + else if (source.stopped) return `throws` + else if (source.gate === `loading`) source.gate = `ready` + return `ok` } const shapeSources: Record> = { @@ -468,6 +537,7 @@ function freshModel(): Model { failureFromReload: false, cleaning: false, retired: false, + retiredCleanup: false, rows: new Map(), open: null, armed: false, @@ -542,7 +612,11 @@ function applyModel( step: Step, ): `ok` | `throws` | `bad-transition` { if (step.kind === `restart`) { + // A retired run's cleanup still pending stays pending. + const { A, B } = model.sources Object.assign(model, freshModel()) + model.sources.A.retiredCleanup = A.retiredCleanup + model.sources.B.retiredCleanup = B.retiredCleanup return `ok` } if (step.kind === `effect`) { @@ -679,13 +753,20 @@ function applyModel( // only cleanup leaves it, so a crash during cleanup ends when cleanup // settles. if (source.retired) { - if (step.kind === `crash`) { + const crashCause = + step.kind === `crash` + ? `${step.source} crashed` + : step.kind === `retired-cleanup-rejects` && source.retiredCleanup + ? `${step.source} cleanup failed` + : null + if (step.kind === `retired-cleanup-rejects`) source.retiredCleanup = false + if (crashCause) { // A crash keeps the first cause. if (source.gate !== `crashed`) { source.gate = `crashed` - source.cause = `${step.source} crashed` + source.cause = crashCause } - } else if (step.kind === `reload` || step.kind === `reload-cleanup-fails`) { + } else if (isReload(step.kind)) { if (source.cleaning || source.gate === `crashed`) return `throws` restartSource(source) } @@ -698,8 +779,7 @@ function applyModel( // closure keeps accepted sync obligations, such as the confirmation of // a write admitted before it (the IndexedDB retirement oracle owns that // law). - if (step.kind === `write`) - openTransaction(source).writes.set(step.key, ++model.serial) + if (step.kind === `write`) writeRow(model, source, step) if (step.kind === `commit`) commitOpen(source) if (step.kind === `cancel`) source.open = null if (step.kind === `truncate`) { @@ -710,9 +790,8 @@ function applyModel( } deriveQueries(shape, model) deriveEffect(model) - return step.kind === `reload` || step.kind === `reload-cleanup-fails` - ? `throws` - : outcome + if (step.kind === `retired-cleanup-rejects`) source.retiredCleanup = false + return isReload(step.kind) ? `throws` : outcome } switch (step.kind) { case `ready`: @@ -720,7 +799,9 @@ function applyModel( // before markReady(), as a first load does. if (source.gate === `loading` || source.reloading) { openTransaction(source).writes.set(1, ++model.serial) - commitOpen(source) + // A commit that throws ends the step before markReady(). + outcome = commitOpen(source) + if (outcome !== `ok`) break } if (source.reloading) completeReload(source) else if (source.gate === `error`) outcome = `bad-transition` @@ -728,27 +809,39 @@ function applyModel( else source.gate = `ready` break case `write`: - openTransaction(source).writes.set(step.key, ++model.serial) + writeRow(model, source, step) break case `commit`: - commitOpen(source) + outcome = commitOpen(source) break case `truncate`: { const open = openTransaction(source) open.writes = new Map() open.truncate = true open.adapter = true + // By default the truncate's commit also marks the Collection ready. + open.marksReady = step.markReady !== false break } case `reload`: + case `reload-cleanup-held`: // Everything visible stays until the new run commits or fails. The // retired run's open transaction is dropped. if (!source.reloading) { source.reloading = true source.armed = true source.open = null + if (step.kind === `reload-cleanup-held`) source.retiredCleanup = true } break + case `retired-cleanup-rejects`: + // The retired run's cleanup rejecting breaks the adapter contract, + // whenever it settles: the Collection crashes, and a reload in flight + // ends with it. + if (!source.retiredCleanup) break + source.retiredCleanup = false + crashSource(source, `${step.source} cleanup failed`) + break case `cancel`: { // A commit canceled by its signal never happened: its writes are gone, // and a reload's replacement waits for the run's next transaction. @@ -815,19 +908,7 @@ function applyModel( } break case `crash`: - // From any active status, for good. A reload ends with it, before its - // first commit: the rows stay the baseline. - if (source.reloading) { - source.armed = false - if (source.open && !source.open.adapter) source.open.truncate = false - } - if (source.gate !== `crashed`) { - source.gate = `crashed` - source.cause = `${step.source} crashed` - source.failure = null - source.stopped = null - source.reloading = false - } + crashSource(source, `${step.source} crashed`) break } deriveQueries(shape, model) @@ -1018,6 +1099,10 @@ function createSource(name: SourceName) { let failNextCleanup = false // A held adapter cleanup stays pending until the driver settles it. let held: ReturnType> | undefined + // The next cleanup, a reload's retired run's, stays pending until the + // driver rejects it. + let holdNextCleanup = false + let retiredHeld: ReturnType> | undefined const collection = createCollection({ id: `health-oracle-${name}-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, @@ -1028,6 +1113,11 @@ function createSource(name: SourceName) { params = next return () => { if (held) return held.promise + if (holdNextCleanup) { + holdNextCleanup = false + retiredHeld ??= createDeferred() + return retiredHeld.promise + } if (!failNextCleanup) return failNextCleanup = false return Promise.reject(new Error(`${name} cleanup failed`)) @@ -1040,6 +1130,13 @@ function createSource(name: SourceName) { failNextCleanup: (armed = true) => { failNextCleanup = armed }, + holdNextCleanup: (armed = true) => { + holdNextCleanup = armed + }, + rejectRetiredCleanup: () => { + retiredHeld?.reject(new Error(`${name} cleanup failed`)) + retiredHeld = undefined + }, holdCleanup: () => { held ??= createDeferred() }, @@ -1200,11 +1297,18 @@ function expectIdentity( // A diffing adapter writes an insert for a key missing from its baseline and // an update for a key it has, so a wrong baseline throws or loses rows. -function writeDiffing(params: SyncParams, key: number, tag: number): void { +function writeDiffing( + params: SyncParams, + key: number, + tag: number, + untagged = false, +): void { const known = new Map(params.collection._state.acceptedSyncedEntries()) params.write({ type: known.has(key) ? `update` : `insert`, - value: { id: key, parentId: key, tag }, + value: untagged + ? { id: key, parentId: key } + : { id: key, parentId: key, tag }, }) } @@ -1213,7 +1317,7 @@ function sortedTags(rows: Rows): Array<[number, number]> { } function tagsOf(rows: ReadonlyArray): Array<[number, number]> { - return sortedTags(new Map(rows.map((row) => [row.id, row.tag!]))) + return sortedTags(new Map(rows.map((row) => [row.id, row.tag ?? 0]))) } // The rows a ready query shows. `join()` is a left join, so a join keeps @@ -1417,14 +1521,20 @@ async function runHistory( begin() } else if (shapeSources[shape].includes(step.source)) { const params = sources[step.source].params - if (step.kind === `reload`) { + if (step.kind === `reload` || step.kind === `reload-cleanup-held`) { // A reload settles only when its new run commits or fails; a // crashed Collection, or one whose cleanup is pending, rejects at // once. + if (step.kind === `reload-cleanup-held`) + sources[step.source].holdNextCleanup() const reload = params.collection.reload() + sources[step.source].holdNextCleanup(false) if (params.collection.status === `crashed` || pre!.cleaning) thrown = await reload.catch((e: unknown) => e) else void reload.catch(() => undefined) + } else if (step.kind === `retired-cleanup-rejects`) { + sources[step.source].rejectRetiredCleanup() + await flushPromises() } else if (step.kind === `reload-cleanup-fails`) { sources[step.source].failNextCleanup() const reload = params.collection.reload() @@ -1446,13 +1556,15 @@ async function runHistory( params.markReady() } else if (step.kind === `write`) { if (!pre!.open) params.begin() - writeDiffing(params, step.key, tag) + writeDiffing(params, step.key, tag, step.untagged) } else if (step.kind === `commit`) { if (!pre!.open) params.begin() params.commit() } else if (step.kind === `truncate`) { if (!pre!.open) params.begin() - params.truncate({ markReady: false }) + params.truncate( + step.markReady === false ? { markReady: false } : undefined, + ) } else if (step.kind === `cancel`) { const canceled = new AbortController() canceled.abort() @@ -1510,7 +1622,7 @@ async function runHistory( sortedTags( new Map( [...collection._state.acceptedSyncedEntries()].map( - ([key, row]) => [key, row.tag!], + ([key, row]) => [key, row.tag ?? 0], ), ), ), @@ -1617,6 +1729,14 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record> = { + "a reload's default truncate after a failed first load makes it ready": [ + on(`fail`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + { kind: `truncate`, source: `A` }, + { kind: `write`, source: `A`, key: 1 }, + { kind: `commit`, source: `A` }, + ], 'an inner query that crashes as it starts stops the outer start': [ { kind: `cleanup-hold`, source: `A` }, { kind: `cleanup-hold`, source: `B` }, @@ -1638,7 +1758,7 @@ const namedHistories: Record> = { { kind: `write`, source: `A`, key: 2 }, { kind: `commit`, source: `A` }, on(`reload`, `A`), - { kind: `truncate`, source: `A` }, + { kind: `truncate`, source: `A`, markReady: false }, { kind: `write`, source: `A`, key: 3 }, on(`fail`, `A`), { kind: `commit`, source: `A` }, @@ -1651,6 +1771,31 @@ const namedHistories: Record> = { on(`fail`, `A`), { kind: `commit`, source: `A` }, ], + "a retired run's late cleanup failure crashes the Collection": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload-cleanup-held`, `A`), + on(`ready`, `A`), + on(`retired-cleanup-rejects`, `A`), + ], + "a retired run's cleanup failure after a cleanup crashes the fresh run": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload-cleanup-held`, `A`), + { kind: `cleanup-hold`, source: `A` }, + { kind: `cleanup-settle`, source: `A` }, + { kind: `restart` }, + on(`ready`, `A`), + on(`retired-cleanup-rejects`, `A`), + ], + "a failed reload's open insert of a kept row replaces it": [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 1, untagged: true }, + on(`fail`, `A`), + { kind: `commit`, source: `A` }, + ], 'a crash during a reload still applies its open write of a kept row': [ on(`ready`, `A`), on(`ready`, `B`), @@ -1913,8 +2058,13 @@ describe(`source failure and health oracle`, () => { .tuple( fc.constantFrom(`A` as const, `B` as const), fc.constantFrom(1 as const, 2 as const, 3 as const), + fc.boolean(), ) - .map(([source, key]): Step => ({ kind: `write`, source, key })), + .map(([source, key, untagged]): Step => + untagged + ? { kind: `write`, source, key, untagged } + : { kind: `write`, source, key }, + ), }, { weight: 1, @@ -1922,8 +2072,11 @@ describe(`source failure and health oracle`, () => { .tuple( fc.constantFrom(`commit` as const, `truncate` as const), fc.constantFrom(`A` as const, `B` as const), + fc.boolean(), ) - .map(([kind, source]): Step => ({ kind, source })), + .map(([kind, source, markReady]): Step => + kind === `truncate` ? { kind, source, markReady } : { kind, source }, + ), }, { weight: 1, arbitrary: fc.constant(restart) }, { weight: 1, arbitrary: fc.constant({ kind: `reload-query` } as const) }, @@ -1949,6 +2102,8 @@ describe(`source failure and health oracle`, () => { `crash` as const, `reload` as const, `reload-cleanup-fails` as const, + `reload-cleanup-held` as const, + `retired-cleanup-rejects` as const, `cancel` as const, ), fc.constantFrom(`A` as const, `B` as const), @@ -3460,20 +3615,25 @@ type DemandModel = { // A source reload's replay is pending; a window move made now waits for // it, and a later reload aborts that move (setWindow() rejects). replayPending: boolean + // A replay failed: the query keeps its last complete snapshot until a + // later replay succeeds, so a window move waits for that replay too. + replayFailed: boolean moveWaitsForReplay: boolean source: { failure: string | null; stopped: string | null; crashed: boolean } serial: number } // The query requested `count` loads at the adapter boundary. A window move -// that requests none is served from rows the query already loaded. +// that requests none is served from rows the query already loaded, unless it +// waits for a replay. function requestedLoads( model: DemandModel, count: number, step?: DemandStep, ): void { if (count === 0) { - if (step === `window` && model.pending === 0) model.shown = model.offset + if (step === `window` && model.pending === 0 && !model.moveWaitsForReplay) + model.shown = model.offset return } model.pending += count @@ -3486,12 +3646,14 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { case `window`: if (model.gate !== `ready`) return model.offset += 2 - if (model.replayPending) model.moveWaitsForReplay = true + if (model.replayPending || model.replayFailed) + model.moveWaitsForReplay = true return case `settle-ok`: if (model.pending === 0) return model.pending = 0 model.replayPending = false + model.replayFailed = false model.moveWaitsForReplay = false if (model.gate === `loading`) model.gate = `ready` if (model.requestError && model.laterLoad) model.requestError = null @@ -3500,6 +3662,7 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { case `settle-fail`: if (model.pending === 0) return model.pending = 0 + if (model.replayPending) model.replayFailed = true model.replayPending = false model.moveWaitsForReplay = false if (model.gate === `loading`) { @@ -3553,6 +3716,7 @@ async function runDemandHistory(steps: ReadonlyArray) { offset: 0, shown: 0, replayPending: false, + replayFailed: false, moveWaitsForReplay: false, source: { failure: null, stopped: null, crashed: false }, serial: 0, @@ -3705,6 +3869,14 @@ describe(`on-demand demand grammar`, () => { `reload`, `settle-fail`, ], + 'a reload aborts a window move that waits after a failed replay': [ + `settle-ok`, + `reload`, + `settle-fail`, + `window`, + `reload`, + `settle-ok`, + ], "a successful replay clears the replay's request error": [ `settle-ok`, `reload`, @@ -3768,12 +3940,29 @@ describe(`on-demand demand grammar`, () => { * replay succeeds; the model does not track that snapshot, so the rows check * waits for the replay to succeed. */ +// `settle-ok` and `settle-fail` settle every pending load; the `first` and +// `last` forms settle only the oldest or the newest one. `throw-next` makes +// the next load the query requests throw synchronously. type LazyStep = | { kind: `parent` | `unparent`; key: RowKey } | { - kind: `settle-ok` | `settle-fail` | `reload` | `report` | `fail` | `crash` + kind: + | `settle-ok` + | `settle-fail` + | `settle-first-ok` + | `settle-first-fail` + | `settle-last-ok` + | `settle-last-fail` + | `throw-next` + | `reload` + | `report` + | `fail` + | `crash` } +// One load the query requested, as the adapter saw it. +type LazyLoad = { serial: number; keys: Set; replay: boolean } + type LazyModel = { gate: `loading` | `ready` | `error` | `crashed` parents: Set @@ -3787,10 +3976,79 @@ type LazyModel = { // demand, so its error stays until a replay succeeds or the query has no // demand left. replayFailed: boolean - pending: Array<{ keys: Set; replay: boolean }> + pending: Array + // Keys a replay load failed for; the replay reports the failure once all + // its loads settle, if the query still needs one of them then. + replayFailedKeys: Set + // Aborted loads the current run may still finish: their rows apply. + obsolete: Map> + // Every load the query requested and did not abort, by serial: a key's + // latest such load decides it, so an older load that settles later changes + // nothing, and an aborted one leaves the decision to the one before it. + requested: Map> source: { failure: string | null; stopped: string | null; crashed: boolean } } +// A load the query requested settles. Its rows apply on success either way; +// its outcome decides each key it was the latest load of. +function settleLazyLoad( + model: LazyModel, + load: LazyLoad, + succeeded: boolean, +): void { + model.pending = model.pending.filter((entry) => entry !== load) + if (model.gate === `crashed`) return + const latestFor = (key: number) => latestLoadFor(model, key) === load.serial + if (succeeded) { + for (const key of load.keys) { + model.loaded.add(key) + if (latestFor(key)) model.failed.delete(key) + } + if (load.replay) model.replayFailed = false + settleReplay(model) + return + } + // Only the latest load of a key the query still needs decides it. + const needed = [...load.keys].filter( + (key) => model.parents.has(key) && latestFor(key), + ) + // Before initial-query readiness, that failure fails the query's first + // load. + if (model.gate === `loading` && !load.replay) { + if (needed.length > 0) model.gate = `error` + return + } + if (load.replay) { + for (const key of needed) model.replayFailedKeys.add(key) + } else for (const key of needed) model.failed.add(key) + settleReplay(model) +} + +// The serial of the latest load the query requested for a key and did not +// abort. +function latestLoadFor(model: LazyModel, key: number): number | undefined { + let latest: number | undefined + for (const [serial, keys] of model.requested) + if (keys.has(key) && (latest === undefined || serial > latest)) + latest = serial + return latest +} + +// A replay settles once none of its loads is pending; one that failed for a +// key the query still needs then fails the query's first load, or shows a +// request error after it. +function settleReplay(model: LazyModel): void { + if (model.pending.some((entry) => entry.replay)) return + model.replayActive = false + const failed = [...model.replayFailedKeys].some((key) => + model.parents.has(key), + ) + model.replayFailedKeys = new Set() + if (!failed) return + if (model.gate === `loading`) model.gate = `error` + else model.replayFailed = true +} + // Keys a subset request asks for, read from its predicate's literal values. function requestedKeys(options: LoadSubsetOptions): Set { const keys = new Set() @@ -3812,11 +4070,15 @@ function requestedKeys(options: LoadSubsetOptions): Set { } // During the first load, the query is ready once no pending load is for a -// key it still needs: a demand it retired no longer holds readiness back. +// key it still needs: a demand it retired no longer holds readiness back. A +// source reload's replay restores the demand as one unit, so the query also +// waits until the replay settles. function deriveLazyGate(model: LazyModel): void { - if (model.gate !== `loading`) return - const blocking = model.pending.some(({ keys }) => - [...keys].some((key) => model.parents.has(key)), + if (model.gate !== `loading` || model.replayActive) return + const blocking = model.pending.some(({ serial, keys }) => + [...keys].some( + (key) => model.parents.has(key) && latestLoadFor(model, key) === serial, + ), ) if (!blocking) model.gate = `ready` } @@ -3832,34 +4094,14 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.failed.delete(step.key) if (model.parents.size === 0) model.replayFailed = false return + // Settle steps reach the model one load at a time, from the driver. case `settle-ok`: - for (const { keys, replay } of model.pending) { - for (const key of keys) { - model.loaded.add(key) - model.failed.delete(key) - } - if (replay) model.replayFailed = false - } - model.pending = [] - model.replayActive = false - if (model.gate === `loading`) model.gate = `ready` - return case `settle-fail`: - if (model.pending.length === 0) return - if (model.gate === `loading`) { - model.gate = `error` - } else { - for (const { keys, replay } of model.pending) { - // A replay fails the query's data only for demand it still needs. - for (const key of keys) { - if (!model.parents.has(key)) continue - if (replay) model.replayFailed = true - else model.failed.add(key) - } - } - } - model.pending = [] - model.replayActive = false + case `settle-first-ok`: + case `settle-first-fail`: + case `settle-last-ok`: + case `settle-last-fail`: + case `throw-next`: return case `reload`: if (model.source.crashed) return @@ -3869,6 +4111,7 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { // The retired run's loads no longer apply, and its truncate drops the // loaded children until the replay loads them again. model.pending = [] + model.obsolete = new Map() model.loaded = new Set() model.replayActive = true return @@ -3888,7 +4131,17 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { } } -async function runLazyHistory(steps: ReadonlyArray) { +// `asyncChildReady`: the child Collection's reload calls markReady() after +// sync() returns, so its loads in flight settle before the replay exists. +// `honorsAbort`: the loader rejects a request whose signal aborts; one that +// does not may still finish it later. +async function runLazyHistory( + steps: ReadonlyArray, + { + asyncChildReady = false, + honorsAbort = true, + }: { asyncChildReady?: boolean; honorsAbort?: boolean } = {}, +) { const model: LazyModel = { gate: `loading`, parents: new Set([1]), @@ -3897,11 +4150,24 @@ async function runLazyHistory(steps: ReadonlyArray) { replayActive: false, replayFailed: false, pending: [], + replayFailedKeys: new Set(), + obsolete: new Map(), + requested: new Map(), source: { failure: null, stopped: null, crashed: false }, } - const loads: Array>> = [] - const requests: Array> = [] + // Loads in flight at the adapter, oldest first, and what the adapter saw + // since the last report: each request, and which of them were aborted or + // threw. + const loads: Array<{ + serial: number + deferred: ReturnType> + }> = [] + const requests: Array = [] + const aborted = new Set() + let serials = 0 + let throwNext = false let failures = 0 + let childRuns = 0 let parentParams!: SyncParams let childParams!: SyncParams const parents = createCollection({ @@ -3929,14 +4195,29 @@ async function runLazyHistory(steps: ReadonlyArray) { // Children this sync run has served; a reload's run serves them // again. const served = new Set() - params.markReady() + if (childRuns++ > 0 && asyncChildReady) + setTimeout(() => params.markReady(), 0) + else params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { const keys = requestedKeys(options) - requests.push(keys) - const load = createDeferred() - loads.push(load) - return load.promise.then(() => { + const serial = ++serials + const thrown = throwNext + throwNext = false + requests.push({ serial, keys, replay: false, thrown }) + if (thrown) throw new Error(`child load ${++failures} threw`) + const deferred = createDeferred() + loads.push({ serial, deferred }) + // The adapter sees every abort. One that honors aborts also + // rejects the request; one that does not may still finish it. + options.signal?.addEventListener(`abort`, () => { + aborted.add(serial) + if (!honorsAbort) return + const index = loads.findIndex((load) => load.serial === serial) + if (index !== -1) loads.splice(index, 1) + deferred.reject(new DOMException(`aborted`, `AbortError`)) + }) + return deferred.promise.then(() => { params.begin() for (const key of keys) { if (served.has(key)) continue @@ -3964,9 +4245,58 @@ async function runLazyHistory(steps: ReadonlyArray) { console.error = () => {} // Requests made since the last report, tagged with whether a source // reload's replay made them. - const report = () => { - for (const keys of requests.splice(0)) - model.pending.push({ keys, replay: model.replayActive }) + const report = (replayMade = false) => { + // An aborted request is obsolete: it decides nothing, but if its run + // still serves it, its rows apply when it succeeds. + const retireAborted = () => { + for (const load of model.pending) + if (aborted.has(load.serial)) model.obsolete.set(load.serial, load.keys) + model.pending = model.pending.filter((load) => !aborted.has(load.serial)) + for (const serial of aborted) model.requested.delete(serial) + } + // A demand change releases the loads it retires before it requests new + // ones, so releasing a replay's last pending load settles the replay + // first. + retireAborted() + if (!replayMade) settleReplay(model) + // A request a new demand makes during a replay joins the replay, but one + // that throws fails at once; one the replay makes fails with the replay. + // Every request is recorded before a thrown one settles, so the replay's + // later requests still belong to it. + const reported = requests.splice(0).map((request) => { + const load: LazyLoad = { + serial: request.serial, + keys: request.keys, + replay: model.replayActive && (replayMade || !request.thrown), + } + model.requested.set(load.serial, load.keys) + model.pending.push(load) + return { load, thrown: request.thrown } + }) + for (const { load, thrown } of reported) + if (thrown) settleLazyLoad(model, load, false) + retireAborted() + aborted.clear() + settleReplay(model) + } + // Settle loads at the adapter and report each outcome to the model. + const settle = ( + picked: Array<(typeof loads)[number]>, + succeeded: boolean, + ) => { + for (const { serial, deferred } of picked) { + loads.splice( + loads.findIndex((load) => load.serial === serial), + 1, + ) + const load = model.pending.find((entry) => entry.serial === serial) + if (load) settleLazyLoad(model, load, succeeded) + else if (succeeded && model.obsolete.has(serial)) + for (const key of model.obsolete.get(serial)!) model.loaded.add(key) + model.obsolete.delete(serial) + if (succeeded) deferred.resolve() + else deferred.reject(new Error(`child load ${++failures} failed`)) + } } try { void query.preload().catch(() => undefined) @@ -3984,6 +4314,9 @@ async function runLazyHistory(steps: ReadonlyArray) { // Only a demand change can leave every fact as it was; a reload, a // settled load, or a source call can change a fact and change it back. const demandOnly = step.kind === `parent` || step.kind === `unparent` + // Open (see the request error check below): a demand change during a + // replay can show a request error and clear it at once. + const replayBefore = model.replayActive applyLazyModel(model, step) if (step.kind === `parent` || step.kind === `unparent`) { parentParams.begin() @@ -3994,14 +4327,20 @@ async function runLazyHistory(steps: ReadonlyArray) { parentParams.commit() } else if (step.kind === `settle-ok`) { for (let round = 0; round < 5 && loads.length > 0; round++) { - for (const load of loads.splice(0)) load.resolve() + settle([...loads], true) await flushPromises() - if (round > 0) report() + report() } } else if (step.kind === `settle-fail`) { // Each failure has its own message, so two problems never look alike. - for (const load of loads.splice(0)) - load.reject(new Error(`child load ${++failures} failed`)) + settle([...loads], false) + } else if (step.kind.startsWith(`settle-`)) { + const pick = step.kind.startsWith(`settle-first`) + ? loads[0] + : loads.at(-1) + if (pick) settle([pick], step.kind.endsWith(`-ok`)) + } else if (step.kind === `throw-next`) { + throwNext = true } else if (step.kind === `reload`) { void children.reload().catch(() => undefined) } else if (step.kind === `crash`) { @@ -4016,12 +4355,16 @@ async function runLazyHistory(steps: ReadonlyArray) { } } await flushPromises() - report() + report(step.kind === `reload`) deriveLazyGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) expect(query.errors, `${label}: stable read`).toBe(query.errors) - if (demandOnly && JSON.stringify(project(query.errors)) === factsBefore) { + if ( + demandOnly && + !replayBefore && + JSON.stringify(project(query.errors)) === factsBefore + ) { expect(query.errors, `${label}: unchanged tree keeps identity`).toBe( treeBefore, ) @@ -4065,7 +4408,7 @@ async function runLazyHistory(steps: ReadonlyArray) { } } finally { console.error = consoleError - for (const load of loads.splice(0)) load.resolve() + for (const { deferred } of loads.splice(0)) deferred.resolve() await query.cleanup() await parents.cleanup() await children.cleanup({ dependents: true }) @@ -4081,6 +4424,11 @@ describe(`lazy join demand grammar`, () => { .constantFrom( `settle-ok` as const, `settle-fail` as const, + `settle-first-ok` as const, + `settle-first-fail` as const, + `settle-last-ok` as const, + `settle-last-fail` as const, + `throw-next` as const, `reload` as const, `report` as const, `fail` as const, @@ -4128,6 +4476,13 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-fail` }, { kind: `unparent`, key: 2 }, ], + 'a load that throws keeps its error until its key loads again': [ + { kind: `settle-ok` }, + { kind: `throw-next` }, + { kind: `parent`, key: 2 }, + { kind: `parent`, key: 3 }, + { kind: `settle-ok` }, + ], 'a failed replacement keeps its error while a still-needed key is missing': [ { kind: `settle-ok` }, @@ -4142,12 +4497,81 @@ describe(`lazy join demand grammar`, () => { } for (const [name, steps] of Object.entries(named)) it(name, () => runLazyHistory(steps)) + it(`an older load's late success does not erase its key's newer failure`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + { kind: `settle-first-ok` }, + { kind: `unparent`, key: 1 }, + ], + { honorsAbort: false }, + )) + it(`a source reload's replay keeps the error of a load that threw`, () => + runLazyHistory([ + { kind: `throw-next` }, + { kind: `settle-ok` }, + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 1 }, + { kind: `reload` }, + { kind: `settle-ok` }, + ])) + it(`releasing a replay's last pending load settles the replay`, () => + runLazyHistory( + [ + { kind: `reload` }, + { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + ], + { honorsAbort: false }, + )) + it(`a first load waits for a replay load that no demand change follows`, () => + runLazyHistory( + [ + { kind: `parent`, key: 3 }, + { kind: `settle-first-ok` }, + { kind: `reload` }, + { kind: `unparent`, key: 3 }, + ], + { honorsAbort: false }, + )) + it(`a demand change after a failed replay load requests its key again`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + ], + { honorsAbort: false }, + )) + it(`a first load waits for a source reload's whole replay`, () => + runLazyHistory( + [ + { kind: `parent`, key: 3 }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 1 }, + { kind: `reload` }, + { kind: `settle-last-fail` }, + ], + { honorsAbort: false }, + )) + it(`a reload during the first load with a late ready does not fail the query`, () => + runLazyHistory([{ kind: `reload` }, { kind: `settle-ok` }], { + asyncChildReady: true, + })) fcTest.prop( - [fc.array(step, { maxLength: 10 })], + [fc.array(step, { maxLength: 10 }), fc.boolean(), fc.boolean()], oraclePropertyOptions(60, `source-health.lazy-demand`), )( `generated lazy demand histories refine the model`, - (steps) => withDeadline(runLazyHistory(steps)), + (steps, asyncChildReady, honorsAbort) => + withDeadline(runLazyHistory(steps, { asyncChildReady, honorsAbort })), 120_000, ) }) From b1226ce9251ed98b3100f98fea5bb7f18e5102c4 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 14:26:53 -0600 Subject: [PATCH 094/106] refactor(db): start a reload's replay when it retires the sync run A reload now detaches its demand and starts the subscription's truncate replay at once, instead of at the new run's first commit. A consumer of an aborted load follows that replay through the existing truncate path, so the reload-only result wrapper goes away. - The demand controller's segment result follows a pending replay, so a replaced load's own outcome decides nothing, also when it fails after the replay settled (a subscription replacement count tells). - The query subscriber's own redirect is gone; segments follow replays. - A demand change releases failed segments first, so a replay judged during the change does not fail for a demand it requests again. - Lazy grammar: a load a reload retired still holds the first load while in flight (the truncate rule), a success clears a key's failure unless a newer load of it failed, and named histories for each case. db production bundle: -58 B gzip against the wrapper version. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 3 +- packages/db/src/collection/subscription.ts | 66 +++++-------------- packages/db/src/query/live/ARCHITECTURE.md | 5 +- .../src/query/live/collection-subscriber.ts | 14 ++-- .../query/live/subset-demand-controller.ts | 24 ++++++- .../db/tests/source-health-oracle.test.ts | 66 +++++++++++++++++-- 7 files changed, 109 insertions(+), 71 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 10959da071..f3246c912d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Open, found by the widened grammars and present before this change: (lazy) a failed key requested again while an older load of it is pending can keep its request error after a later load succeeds (`[unparent 1, parent 1, parent 2, settle-first-fail, parent 3, settle-last-ok]`, aborts honored); (lazy) a reload during the first load with parents churning can fail the first load for a key the query no longer needs (`[parent 3, reload, settle-first-fail, parent 2, unparent 3, unparent 2]`); (on-demand) two reloads with window moves can show the moved window where the model keeps the last shown one (`[settle-ok, window, reload, window, reload, settle-ok]`). Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 37f553b68a..f70ff84bc4 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -432,6 +432,5 @@ "isDeferredSyncKey": "b6", "overlayHeldAndActive": "e8", "recordOutcome": "gP", - "reloadRetiredAcquisitions": "dp", - "resultAcrossReload": "gR" + "replacements": "dp" } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 310e410a66..b75f5d0e27 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -119,8 +119,6 @@ type SubsetDemand = { acquisition: SubsetAcquisitionRecord acquisitionState: `starting` | `active` | `detached` initialResult?: Deferred - // The result its caller holds while it follows the current acquisition. - caller?: Deferred } type TruncateReplayAttempt = { @@ -206,6 +204,7 @@ export class CollectionSubscription // One replay state owns the publication baseline, overlapping attempts, // and buffered changes until every attempt settles. private truncateReplayState: TruncateReplayState | undefined + private replacements = 0 private readonly loadSubsetPromiseErrors = new WeakMap< Promise, Error @@ -268,7 +267,9 @@ export class CollectionSubscription // When a truncate happens (e.g., from a 409 must-refetch), all collection data is cleared. // We need to re-request all previously loaded subsets to repopulate the data. this.truncateCleanup = this.collection.on(`truncate`, () => { - this.handleTruncate() + // A reload started its replay when it retired the run; the truncate of + // its first commit is part of that replay. + if (!this.collection._lifecycle.reloading) this.handleTruncate() }) this.collectionCleanup = this.collection._onCleanupStart(() => { this.handleCollectionCleanup() @@ -317,11 +318,13 @@ export class CollectionSubscription } /** - * @internal A reload retired the sync run. It keeps each retained demand's - * waiting caller: the new sync run re-acquires the demand and settles it. + * @internal A reload retired the sync run. Its demand detaches from the + * retired run and replays through the new run at once, as after a + * truncate, so a consumer of an aborted load follows that replay. */ public retireSyncRun(): void { this.handleCollectionCleanup(true) + this.handleTruncate() } /** Detach logical demand from work owned by a discarded sync run. */ @@ -334,18 +337,6 @@ export class CollectionSubscription if (!keepWaitingCallers || demand.acquisitionState === `starting`) { demand.initialResult?.reject(new LoadSubsetOperationAbortedError()) } - // A reload keeps the waiting caller: the new run's replay of this - // demand settles it, and the retired acquisition decides nothing. - const caller = demand.caller - if ( - keepWaitingCallers && - demand.acquisitionState !== `starting` && - caller?.isPending() - ) { - this.reloadRetiredAcquisitions.add(demand.acquisition) - demand.initialResult ??= createDeferred() - void demand.initialResult.promise.then(caller.resolve, caller.reject) - } cancelAcquisition(demand.acquisition) if (demand.acquisitionState === `starting`) { const index = this.subsetDemands.indexOf(demand) @@ -431,6 +422,7 @@ export class CollectionSubscription * authoritative replay succeeds. */ private handleTruncate() { + this.replacements++ // Eager demand is satisfied by source readiness, even when the adapter // happens to expose a loader. Only on-demand demand reacquires a subset. // A reload's truncate can apply inside the new run's `sync()`, before it @@ -871,6 +863,11 @@ export class CollectionSubscription this.checkTruncateReplayComplete(replayState) } + /** Changes each time a truncate or a reload replaces this subscription's loads. */ + public get replacementCount(): number { + return this.replacements + } + public get hasPendingTruncateReplacement(): boolean { return this.truncateReplacementPending } @@ -1079,33 +1076,6 @@ export class CollectionSubscription } /** Start and retain the first acquisition for one logical subset demand. */ - /** - * A demand's result for its caller. A reload retires the sync run and - * aborts its acquisitions; a demand that is still active is then served by - * the new run's replay of it, so the abort is not this demand's outcome. - */ - private readonly reloadRetiredAcquisitions = new WeakSet() - - private resultAcrossReload( - demand: SubsetDemand, - result: LoadSubsetRequestResult, - ): LoadSubsetRequestResult { - if (!(result instanceof Promise)) return result - const acquisition = demand.acquisition - const caller = createDeferred() - demand.caller = caller - const retired = () => this.reloadRetiredAcquisitions.has(acquisition) - void result.then( - () => { - if (!retired()) caller.resolve() - }, - (error: unknown) => { - if (!retired()) caller.reject(error) - }, - ) - return caller.promise - } - private startSubsetDemand(requestOptions: LoadSubsetOptions): { demand: SubsetDemand result: LoadSubsetRequestResult @@ -1325,13 +1295,9 @@ export class CollectionSubscription } = this.startSubsetDemand(loadOptions) if (!this.isDemandActive(demand)) return false - // Report the result synchronously, including a wait for an unavailable - // loader. A caller that aborts its own request follows a reload's replay - // of it; the ordered loader follows replays itself. + // Report the result synchronously, including a wait for an unavailable loader. opts?.onLoadSubsetResult?.( - started && opts.signal - ? this.resultAcrossReload(demand, syncResult) - : syncResult, + syncResult, demand.acquisition.options, (primaryFailure) => this.releaseDemand(demand, primaryFailure), ) diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 66a2f5e311..3f5227ff95 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1698,7 +1698,10 @@ every row before the top-K (a recorded limit). load or a root Collection's load that fails after initial-query readiness is an `error` on the query's own node. A lazy demand's error clears when that demand settles or is released; a root Collection's error clears when - a later load of that Collection settles. A source reload's replay loads + a later load of that Collection settles. A source reload starts its + replay when it retires the sync run, as a truncate does, and the replay + decides every load it replaces: a replaced load's own outcome decides + nothing, even when it settles after the replay. The replay loads the query's demand again as one unit: the query's first load waits for the whole replay, the replay fails only for a demand the query still holds when it settles, and its success clears only the errors of demands diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index a5ad23b27e..2a48842cc9 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -237,16 +237,10 @@ export class CollectionSubscriber< clearIfLoaded() } if (update.ready instanceof Promise) { - const settleWith = (ready: Promise) => - void ready.then(settle, (error) => { - // A truncate replay aborts the acquisition it supersedes. Its - // replacement decides the demand; a real failure rejects it too. - const replacement = subscription.pendingTruncateReplacement - if (replacement) settleWith(replacement) - else - this.collectionConfigBuilder.failDemand(plan.id, generation, error) - }) - settleWith(update.ready) + // Each segment follows a truncate's or a reload's replay itself. + void update.ready.then(settle, (error) => + this.collectionConfigBuilder.failDemand(plan.id, generation, error), + ) } else { settle() } diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index 8acb2c1799..b9ab060e2b 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -110,6 +110,11 @@ export class SubsetDemandController { } this.states.set(plan.id, state) + // A failed segment is released first, so a replay judged during this + // change does not fail for the demand the change requests again. + retired.sort( + (a, b) => Number(b.state === `failed`) - Number(a.state === `failed`), + ) for (const segment of retired) releaseSegment(subscription, segment) if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } @@ -327,7 +332,8 @@ function startSegment( onUnoptimized, onLoadSubsetResult: (result) => { observed = true - segment.ready = result + segment.ready = + result instanceof Promise ? followReplay(subscription, result) : result segment.state = result instanceof Promise ? `pending` : `settled` }, }) @@ -346,6 +352,22 @@ function startSegment( return requested && observed } +// A truncate's or a reload's replay replaces a load that it aborts, and a load +// that fails while the replay is in flight fails with it: the replay decides. +// A replaced load that fails after the replay settled decides nothing. +function followReplay( + subscription: CollectionSubscription, + ready: Promise, +): Promise { + const replacements = subscription.replacementCount + return ready.then(undefined, (error: unknown) => { + const replay = subscription.pendingTruncateReplacement + if (replay) return followReplay(subscription, replay) + if (replacements === subscription.replacementCount) throw error + return undefined + }) +} + function releaseSegment( subscription: CollectionSubscription, segment: DemandSegment, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 7d88d8a3c0..0240a0cc74 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -3967,8 +3967,9 @@ type LazyModel = { gate: `loading` | `ready` | `error` | `crashed` parents: Set loaded: Set - // Needed keys whose last load failed after readiness. - failed: Set + // Needed keys whose last load failed after readiness, with that load's + // serial. + failed: Map // A source reload's replay is in flight: every load the query starts // joins it, inside its publication barrier. replayActive: boolean @@ -3982,6 +3983,11 @@ type LazyModel = { replayFailedKeys: Set // Aborted loads the current run may still finish: their rows apply. obsolete: Map> + // Loads a reload retired that are still in flight at the adapter. As for a + // truncate, a load started before the replay still holds the query's first + // load until it settles, while the query still needs one of its keys; it + // decides nothing else. + retiredInFlight: Map> // Every load the query requested and did not abort, by serial: a key's // latest such load decides it, so an older load that settles later changes // nothing, and an aborted one leaves the decision to the one before it. @@ -4002,7 +4008,8 @@ function settleLazyLoad( if (succeeded) { for (const key of load.keys) { model.loaded.add(key) - if (latestFor(key)) model.failed.delete(key) + // A key loaded: its failure clears, unless a newer load of it failed. + if ((model.failed.get(key) ?? 0) < load.serial) model.failed.delete(key) } if (load.replay) model.replayFailed = false settleReplay(model) @@ -4020,7 +4027,7 @@ function settleLazyLoad( } if (load.replay) { for (const key of needed) model.replayFailedKeys.add(key) - } else for (const key of needed) model.failed.add(key) + } else for (const key of needed) model.failed.set(key, load.serial) settleReplay(model) } @@ -4074,7 +4081,14 @@ function requestedKeys(options: LoadSubsetOptions): Set { // source reload's replay restores the demand as one unit, so the query also // waits until the replay settles. function deriveLazyGate(model: LazyModel): void { - if (model.gate !== `loading` || model.replayActive) return + if ( + model.gate !== `loading` || + model.replayActive || + [...model.retiredInFlight.values()].some((keys) => + [...keys].some((key) => model.parents.has(key)), + ) + ) + return const blocking = model.pending.some(({ serial, keys }) => [...keys].some( (key) => model.parents.has(key) && latestLoadFor(model, key) === serial, @@ -4110,6 +4124,8 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.source.stopped = null // The retired run's loads no longer apply, and its truncate drops the // loaded children until the replay loads them again. + for (const { serial, keys } of model.pending) + model.retiredInFlight.set(serial, keys) model.pending = [] model.obsolete = new Map() model.loaded = new Set() @@ -4146,12 +4162,13 @@ async function runLazyHistory( gate: `loading`, parents: new Set([1]), loaded: new Set(), - failed: new Set(), + failed: new Map(), replayActive: false, replayFailed: false, pending: [], replayFailedKeys: new Set(), obsolete: new Map(), + retiredInFlight: new Map(), requested: new Map(), source: { failure: null, stopped: null, crashed: false }, } @@ -4213,6 +4230,7 @@ async function runLazyHistory( options.signal?.addEventListener(`abort`, () => { aborted.add(serial) if (!honorsAbort) return + model.retiredInFlight.delete(serial) const index = loads.findIndex((load) => load.serial === serial) if (index !== -1) loads.splice(index, 1) deferred.reject(new DOMException(`aborted`, `AbortError`)) @@ -4258,6 +4276,11 @@ async function runLazyHistory( // ones, so releasing a replay's last pending load settles the replay // first. retireAborted() + // A demand change that requests a failed key again releases the failed + // demand first, so the replay no longer fails for it. + if (!replayMade) + for (const request of requests) + for (const key of request.keys) model.replayFailedKeys.delete(key) if (!replayMade) settleReplay(model) // A request a new demand makes during a replay joins the replay, but one // that throws fails at once; one the replay makes fails with the replay. @@ -4275,6 +4298,15 @@ async function runLazyHistory( }) for (const { load, thrown } of reported) if (thrown) settleLazyLoad(model, load, false) + // A demand change that requests every needed key again replaces the + // older loads: readiness waits only for the replacement. + if ( + !replayMade && + reported.some(({ load }) => + [...model.parents].every((key) => load.keys.has(key)), + ) + ) + model.retiredInFlight.clear() retireAborted() aborted.clear() settleReplay(model) @@ -4294,6 +4326,7 @@ async function runLazyHistory( else if (succeeded && model.obsolete.has(serial)) for (const key of model.obsolete.get(serial)!) model.loaded.add(key) model.obsolete.delete(serial) + model.retiredInFlight.delete(serial) if (succeeded) deferred.resolve() else deferred.reject(new Error(`child load ${++failures} failed`)) } @@ -4497,6 +4530,18 @@ describe(`lazy join demand grammar`, () => { } for (const [name, steps] of Object.entries(named)) it(name, () => runLazyHistory(steps)) + it(`an older load's success clears its key's failure while a newer load is pending`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 3 }, + { kind: `settle-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `settle-first-ok` }, + ], + { honorsAbort: false }, + )) it(`an older load's late success does not erase its key's newer failure`, () => runLazyHistory( [ @@ -4550,6 +4595,15 @@ describe(`lazy join demand grammar`, () => { ], { honorsAbort: false }, )) + it(`a reload's retired load that fails after the replay decides nothing`, () => + runLazyHistory( + [ + { kind: `reload` }, + { kind: `settle-last-ok` }, + { kind: `settle-first-fail` }, + ], + { honorsAbort: false }, + )) it(`a first load waits for a source reload's whole replay`, () => runLazyHistory( [ From 31a3a119eea155a7cb1e0713c9df6cb97418a3d7 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 14:49:24 -0600 Subject: [PATCH 095/106] fix(db): close the open lazy and window histories the wider grammars found - A lazy demand's request error clears as soon as a later load of its failed key succeeds; it no longer waits for the demand's other loads. The demand controller reports each recorded success. - A load that fails while a replay is in flight fails with its own error once the replay settles, not with the replay's. - Two requests that fail with one error show one problem, so clearing one keeps the node. - Lazy model: a key requested again does not drop a replay failure; a replacement keeps the old coverage held until it applies. - On-demand model: a reload keeps a window move that joined the replay and aborts only a later move that waited for it. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 3 +- packages/db/src/collection/lifecycle.ts | 5 +- packages/db/src/query/live/ARCHITECTURE.md | 5 +- .../src/query/live/collection-subscriber.ts | 21 ++++---- .../query/live/subset-demand-controller.ts | 23 ++++++--- .../db/tests/source-health-oracle.test.ts | 50 ++++++++++++++++--- 7 files changed, 79 insertions(+), 30 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index f3246c912d..4080f6fb26 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Open, found by the widened grammars and present before this change: (lazy) a failed key requested again while an older load of it is pending can keep its request error after a later load succeeds (`[unparent 1, parent 1, parent 2, settle-first-fail, parent 3, settle-last-ok]`, aborts honored); (lazy) a reload during the first load with parents churning can fail the first load for a key the query no longer needs (`[parent 3, reload, settle-first-fail, parent 2, unparent 3, unparent 2]`); (on-demand) two reloads with window moves can show the moved window where the model keeps the last shown one (`[settle-ok, window, reload, window, reload, settle-ok]`). Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index f70ff84bc4..bfb94316b8 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -432,5 +432,6 @@ "isDeferredSyncKey": "b6", "overlayHeldAndActive": "e8", "recordOutcome": "gP", - "replacements": "dp" + "replacements": "dp", + "onLoaded": "gR" } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 690d2bc17a..72eaf63190 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -796,7 +796,10 @@ export class RequestErrors { fail(key: string, error: unknown): void { this.failed.delete(key) - this.problem = describeProblem(error) + // Two requests that failed with one error show one problem. + this.problem = + [...this.failed.values()].find((problem) => problem.cause === error) ?? + describeProblem(error) this.failed.set(key, this.problem) } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 3f5227ff95..ac4e3eec35 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1701,7 +1701,10 @@ every row before the top-K (a recorded limit). a later load of that Collection settles. A source reload starts its replay when it retires the sync run, as a truncate does, and the replay decides every load it replaces: a replaced load's own outcome decides - nothing, even when it settles after the replay. The replay loads + nothing, even when it settles after the replay. A load that fails while + the replay is in flight fails with its own error once the replay + settles. A lazy demand's error clears as soon as a later load of its + failed key succeeds, without waiting for its other loads. The replay loads the query's demand again as one unit: the query's first load waits for the whole replay, the replay fails only for a demand the query still holds when it settles, and its success clears only the errors of demands diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 2a48842cc9..53cdb0b9e4 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -44,7 +44,10 @@ export class CollectionSubscriber< // Direct load tracking callback for ordered path (set during subscribeToOrderedChanges, // used by loadNextItems for subsequent requestLimitedSnapshot calls) private orderedLoader: OrderedSourceLoader | undefined - private readonly demand = new SubsetDemandController() + // A request error clears as soon as a later load of its failed key succeeds. + private readonly demand = new SubsetDemandController((planId) => + this.collectionConfigBuilder.clearFailedRequest(planId), + ) constructor( private sourceId: string, @@ -224,18 +227,12 @@ export class CollectionSubscriber< plan.id, update.ready instanceof Promise, ) - // The request error clears once the query needs no key whose latest - // load failed: when a later load of that key succeeds, or when the - // query stops needing it. - const clearIfLoaded = () => { - if (!this.demand.hasFailedKeys(plan.id)) - this.collectionConfigBuilder.clearFailedRequest(plan.id) - } - clearIfLoaded() - const settle = () => { + // The request error also clears when the query stops needing the + // failed key. + if (!this.demand.hasFailedKeys(plan.id)) + this.collectionConfigBuilder.clearFailedRequest(plan.id) + const settle = () => this.collectionConfigBuilder.settleDemand(plan.id, generation) - clearIfLoaded() - } if (update.ready instanceof Promise) { // Each segment follows a truncate's or a reload's replay itself. void update.ready.then(settle, (error) => diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index b9ab060e2b..5d69cb3fad 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -44,6 +44,9 @@ export type DemandUpdate = { */ export class SubsetDemandController { private readonly states = new Map() + + /** `onLoaded`: a load left the plan with no key whose latest load failed. */ + constructor(private readonly onLoaded?: (planId: string) => void) {} private readonly warnedPlans = new Set() private valueIdentity = createValueIdentity() @@ -211,6 +214,7 @@ export class SubsetDemandController { if (succeeded) state.failedKeys.delete(key) else if (state.keys.has(key)) state.failedKeys.add(key) } + if (succeeded && state.failedKeys.size === 0) this.onLoaded?.(planId) } clear(): void { @@ -352,9 +356,10 @@ function startSegment( return requested && observed } -// A truncate's or a reload's replay replaces a load that it aborts, and a load -// that fails while the replay is in flight fails with it: the replay decides. -// A replaced load that fails after the replay settled decides nothing. +// A truncate's or a reload's replay replaces a load that it aborts: the replay +// decides it, and its own late failure decides nothing. A load that fails +// while the replay is in flight fails with its own error once the replay +// settles. function followReplay( subscription: CollectionSubscription, ready: Promise, @@ -362,9 +367,15 @@ function followReplay( const replacements = subscription.replacementCount return ready.then(undefined, (error: unknown) => { const replay = subscription.pendingTruncateReplacement - if (replay) return followReplay(subscription, replay) - if (replacements === subscription.replacementCount) throw error - return undefined + const replaced = replacements !== subscription.replacementCount + if (replaced) return replay && followReplay(subscription, replay) + if (!replay) throw error + // The load joined the replay: its own failure stands once the replay + // settles. + const fail = () => { + throw error + } + return replay.then(fail, fail) }) } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 0240a0cc74..4dd366d13f 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -3619,6 +3619,10 @@ type DemandModel = { // later replay succeeds, so a window move waits for that replay too. replayFailed: boolean moveWaitsForReplay: boolean + // The window before the move that waits for a replay: a reload that + // aborts the move returns to it. A move with its own load joins the + // replay, so the replay loads it. + replayedOffset: number source: { failure: string | null; stopped: string | null; crashed: boolean } serial: number } @@ -3645,9 +3649,11 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { switch (step) { case `window`: if (model.gate !== `ready`) return - model.offset += 2 - if (model.replayPending || model.replayFailed) + if (model.replayPending || model.replayFailed) { + if (!model.moveWaitsForReplay) model.replayedOffset = model.offset model.moveWaitsForReplay = true + } + model.offset += 2 return case `settle-ok`: if (model.pending === 0) return @@ -3682,7 +3688,7 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { model.source.failure = null model.source.stopped = null if (model.moveWaitsForReplay) { - model.offset = model.shown + model.offset = model.replayedOffset model.moveWaitsForReplay = false } model.replayPending = true @@ -3718,6 +3724,7 @@ async function runDemandHistory(steps: ReadonlyArray) { replayPending: false, replayFailed: false, moveWaitsForReplay: false, + replayedOffset: 0, source: { failure: null, stopped: null, crashed: false }, serial: 0, } @@ -3869,6 +3876,8 @@ describe(`on-demand demand grammar`, () => { `reload`, `settle-fail`, ], + 'a reload keeps a window move that joined the replay and aborts a later one': + [`settle-ok`, `window`, `reload`, `window`, `reload`, `settle-ok`], 'a reload aborts a window move that waits after a failed replay': [ `settle-ok`, `reload`, @@ -4276,11 +4285,6 @@ async function runLazyHistory( // ones, so releasing a replay's last pending load settles the replay // first. retireAborted() - // A demand change that requests a failed key again releases the failed - // demand first, so the replay no longer fails for it. - if (!replayMade) - for (const request of requests) - for (const key of request.keys) model.replayFailedKeys.delete(key) if (!replayMade) settleReplay(model) // A request a new demand makes during a replay joins the replay, but one // that throws fails at once; one the replay makes fails with the replay. @@ -4604,6 +4608,36 @@ describe(`lazy join demand grammar`, () => { ], { honorsAbort: false }, )) + it(`a later load of the failed key clears its error while another load is pending`, () => + runLazyHistory([ + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 1 }, + { kind: `parent`, key: 2 }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 3 }, + { kind: `settle-last-ok` }, + ])) + it(`a load that fails during a replay keeps its own error`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `parent`, key: 3 }, + { kind: `settle-last-fail` }, + { kind: `settle-first-fail` }, + { kind: `unparent`, key: 3 }, + ], + { honorsAbort: false }, + )) + it(`a reload during the first load fails it only for a demand still held`, () => + runLazyHistory([ + { kind: `parent`, key: 3 }, + { kind: `reload` }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 3 }, + { kind: `unparent`, key: 2 }, + ])) it(`a first load waits for a source reload's whole replay`, () => runLazyHistory( [ From 393e5e8ee3c1c84d586a58bdb76ff969e533c385 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 14:57:36 -0600 Subject: [PATCH 096/106] fix(db): a newer replay replaces a load that failed during an older one A load that failed while a replay was in flight waits for that replay, then keeps its own failure. When a second reload discards the replay meanwhile, the load is replaced: it now follows the newer replay instead of failing the query. The lazy model drops a discarded replay's failures, and a named history pins the case. The first recorded trace of the deferred demand-change-during-replay class now passes; the coverage map records the trace that remains. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../query/live/subset-demand-controller.ts | 19 ++++++++++--------- .../db/tests/source-health-oracle.test.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 4080f6fb26..93084550c3 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change while a source reload's replay is in flight can clear the query's request error before the failed key loads again (trace: remove and re-add parent 1, its load fails, reload the child source, add parent 3); the maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index 5d69cb3fad..eb3bf8e378 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -365,18 +365,19 @@ function followReplay( ready: Promise, ): Promise { const replacements = subscription.replacementCount - return ready.then(undefined, (error: unknown) => { + const decide = (error: unknown): Promise | undefined => { const replay = subscription.pendingTruncateReplacement - const replaced = replacements !== subscription.replacementCount - if (replaced) return replay && followReplay(subscription, replay) + if (replacements !== subscription.replacementCount) + return replay && followReplay(subscription, replay) if (!replay) throw error // The load joined the replay: its own failure stands once the replay - // settles. - const fail = () => { - throw error - } - return replay.then(fail, fail) - }) + // settles, unless a newer replay replaced it meanwhile. + return replay.then( + () => decide(error), + () => decide(error), + ) + } + return ready.then(undefined, decide) } function releaseSegment( diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 4dd366d13f..5018721d4d 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -4135,6 +4135,8 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { // loaded children until the replay loads them again. for (const { serial, keys } of model.pending) model.retiredInFlight.set(serial, keys) + // A replay this reload discards reports nothing. + model.replayFailedKeys = new Set() model.pending = [] model.obsolete = new Map() model.loaded = new Set() @@ -4638,6 +4640,17 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, { kind: `unparent`, key: 2 }, ])) + it(`a second reload replaces a load that failed during the first replay`, () => + runLazyHistory( + [ + { kind: `reload` }, + { kind: `parent`, key: 3 }, + { kind: `settle-last-fail` }, + { kind: `reload` }, + { kind: `settle-ok` }, + ], + { honorsAbort: false }, + )) it(`a first load waits for a source reload's whole replay`, () => runLazyHistory( [ From 889ff08d2fbb6ff207d96d14a45bc4a23af916f7 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 15:02:53 -0600 Subject: [PATCH 097/106] test(solid-db): Suspense keeps rows shown through a sync problem after readiness A source's reportError() and fail() after a query's initial-query readiness leave a Suspense boundary's rows shown and the query ready. A hook whose status follows the error tree fails the witness. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/suspense-after-readiness.test.tsx | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/solid-db/tests/suspense-after-readiness.test.tsx diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 93084550c3..298b23fa63 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -634,7 +634,7 @@ generic visitor behavior outside the named collectors. | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | +| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the error tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | | Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/solid-db/tests/suspense-after-readiness.test.tsx b/packages/solid-db/tests/suspense-after-readiness.test.tsx new file mode 100644 index 0000000000..ad605aa182 --- /dev/null +++ b/packages/solid-db/tests/suspense-after-readiness.test.tsx @@ -0,0 +1,73 @@ +/** + * Suspense after initial-query readiness witness. + * + * Law (packages/db/src/query/live/ARCHITECTURE.md, law 16): after a live + * query's initial-query readiness, a source's sync problem leaves the query's + * status and rows alone and shows on the source's node. A Suspense boundary + * therefore keeps showing the rows: it does not fall back, and nothing throws. + * The shared conformance scenario `sync-errors-after-readiness` checks the + * same law for the hook without a Suspense boundary. + */ +import { describe, expect, it } from 'vitest' +import { render, waitFor } from '@solidjs/testing-library' +import { createCollection } from '@tanstack/db' +import { Suspense } from 'solid-js' +import { useLiveQuery } from '../src/useLiveQuery' +import type { SyncConfig } from '@tanstack/db' + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +type Post = { id: string; title: string } +type SyncParams = Parameters[`sync`]>[0] + +describe(`useLiveQuery under Suspense after readiness`, () => { + it(`keeps the rows shown when the source reports a problem or stops`, async () => { + let params: SyncParams | undefined + const source = createCollection({ + id: `solid-suspense-after-readiness`, + getKey: (post) => post.id, + sync: { + sync: (next) => { + params = next + next.begin() + next.write({ type: `insert`, value: { id: `p1`, title: `one` } }) + next.commit() + next.markReady() + }, + }, + }) + let query: ReturnType | undefined + function Title() { + query = useLiveQuery((q) => q.from({ post: source })) + return
{(query() as Array)[0]?.title}
+ } + const view = render(() => ( + Waiting}> + + </Suspense> + )) + try { + const title = await view.findByTestId(`title`) + expect(title.textContent).toBe(`one`) + + params!.reportError(new Error(`source problem`)) + await flush() + expect(query!.status).toBe(`ready`) + expect(source.errors.failureReason?.message).toBe(`source problem`) + expect(view.queryByTestId(`waiting`)).toBeNull() + expect(view.getByTestId(`title`).textContent).toBe(`one`) + + params!.fail(new Error(`source gave up`)) + await flush() + expect(query!.status).toBe(`ready`) + expect(source.errors.error?.message).toBe(`source gave up`) + expect(view.queryByTestId(`waiting`)).toBeNull() + expect(view.getByTestId(`title`).textContent).toBe(`one`) + } finally { + view.unmount() + await waitFor(() => expect(true).toBe(true)) + await query?.collection.cleanup() + await source.cleanup() + } + }) +}) From 7a4eb68489fbc88601369e7920464dffb9848ac5 Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Thu, 22 Oct 2026 15:12:45 -0600 Subject: [PATCH 098/106] docs(changeset): state the retired-cleanup crash and the reload's loadSubset timing Co-authored-by: Isaac <no-reply@databricks.com> --- .changeset/source-health-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index e562afb543..12edb3c576 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -17,7 +17,7 @@ Collections now answer two questions separately: can the data be shown (`status` - `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It cannot be recovered in place (`reload()` rejects); `cleanup({ dependents: true })` and a fresh start recover. It reaches every live query and Effect above it. `error` now means only "the first load failed". - Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. After a live query is ready, a failed request for more data (a page, a joined row, or a source reload's replay of its data) is an `error` on the query's own node; the source stays healthy. -- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection, and an adapter cleanup that rejects during a reload crashes the Collection. A query in `error` stays there until its `reload()`. +- `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection. When the cleanup of the sync run that a reload retired rejects, the Collection crashes, also if the rejection comes later. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. - Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. @@ -39,4 +39,4 @@ Breaking changes and migration: | query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.errors`, `reload()` | | infinite-query hooks' `error` (React, Vue, Svelte) | the root node's `error` in `errors`; `fetchNextPage()` still resolves | -Custom sync adapters: keep the run's readiness in your `sync` function. A reload calls `sync` again while `collection.status` still shows the previous run's status, and a reload replaces every row at the new run's first commit, so write a full snapshot then. `reportError()` and `reportHealthy()` throw before the first `markReady()`. `markReady()` after a failed first load throws. Calls after a crash do nothing. `useLiveSuspenseQuery` now throws the first load's original cause instead of a generic error. +Custom sync adapters: keep the run's readiness in your `sync` function. A reload calls `sync` again while `collection.status` still shows the previous run's status, and a reload replaces every row at the new run's first commit, so write a full snapshot then. `reportError()` and `reportHealthy()` throw before the first `markReady()`. `markReady()` after a failed first load throws. Calls after a crash do nothing. For an on-demand Collection, a reload aborts the retired run's `loadSubset` calls and asks the new run's `loadSubset` for the current demand as soon as `sync` returns. Honor the `signal`: a load that ignores the abort holds a query's first load until it settles. `useLiveSuspenseQuery` now throws the first load's original cause instead of a generic error. From 86689124fa20d91d7ec267c2565a01eedec448ea Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Thu, 22 Oct 2026 15:32:55 -0600 Subject: [PATCH 099/106] fix(db): a reload keeps a direct subscriber's rows until its first commit Starting the replay when a reload retires the sync run also ran it for subscriptions with nothing to replay. A direct subscriber with the initial state then received deletes for every retained row before the new run committed, and kept that wrong view when the reload failed. The retire-time replay now starts only for an on-demand subscription with demand; other rows stay published until the reload's first commit. The main grammar gains the missing observation: a direct subscriber on each source whose rows equal the Collection's at every step within one Collection lifetime. It fails on the previous head and passes on the version before the reload rewrite. The lazy model also stops holding the first load for loads a replay made once a newer replay replaces them. Also drop four migration rows from the changeset for names that never shipped, and say how to recover a crashed Collection. Co-authored-by: Isaac <no-reply@databricks.com> --- .changeset/source-health-channel.md | 6 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 3 +- packages/db/src/collection/subscription.ts | 20 ++++-- .../db/tests/source-health-oracle.test.ts | 63 ++++++++++++++++++- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 12edb3c576..6c77bbddf5 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -15,7 +15,7 @@ Collections now answer two questions separately: can the data be shown (`status`), and is it still kept up to date (`errors`). Once a query has shown data, a network or backend failure never takes it off the screen. -- `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). It cannot be recovered in place (`reload()` rejects); `cleanup({ dependents: true })` and a fresh start recover. It reaches every live query and Effect above it. `error` now means only "the first load failed". +- `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). Nothing recovers it in place, and `reload()` rejects. To recover, call `cleanup({ dependents: true })`, then start the Collection again (for example with `preload()`); it starts with no rows. It reaches every live query and Effect above it. `error` now means only "the first load failed". - Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. After a live query is ready, a failed request for more data (a page, a joined row, or a source reload's replay of its data) is an `error` on the query's own node; the source stays healthy. - `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection. When the cleanup of the sync run that a reload retired rejects, the Collection crashes, also if the rejection comes later. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. @@ -29,12 +29,8 @@ Breaking changes and migration: | — | `isSyncError`: `ready` and the error tree has a problem | | `status: 'error'` after a crash | `status: 'crashed'` | | adapter `markError(error)` | `fail(error)`. After readiness it sets the node's `error` and keeps the rows. Later calls throw. | -| `reportError(error, { final })` | `reportError(error)` (sets `failureReason`, after readiness only) or `fail(error)` | | `cleanup()` then `preload()` to recover | `reload()` | | `cleanup()` while live queries or Effects read the Collection | rejects; unmount first or pass `{ dependents: true }` | -| `collection.health`, `health:change` | `collection.errors`, `errors:change` | -| node `phase`, `final`, `isError` | node `failureReason` or `error` | -| Effect `onHealthChange` | `onErrorsChange` | | `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()`, `PERSISTED_READINESS` | `status` and `errors` | | query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.errors`, `reload()` | | infinite-query hooks' `error` (React, Vue, Svelte) | the root node's `error` in `errors`; `fetchNextPage()` still resolves | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 298b23fa63..1b673ce9d6 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index bfb94316b8..8fa5090e93 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -433,5 +433,6 @@ "overlayHeldAndActive": "e8", "recordOutcome": "gP", "replacements": "dp", - "onLoaded": "gR" + "onLoaded": "gR", + "reloadReplay": "gU" } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index b75f5d0e27..6bd029091a 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -205,6 +205,8 @@ export class CollectionSubscription // and buffered changes until every attempt settles. private truncateReplayState: TruncateReplayState | undefined private replacements = 0 + // The replay a reload started when it retired the sync run. + private reloadReplay: TruncateReplayState | undefined private readonly loadSubsetPromiseErrors = new WeakMap< Promise<unknown>, Error @@ -267,9 +269,10 @@ export class CollectionSubscription // When a truncate happens (e.g., from a 409 must-refetch), all collection data is cleared. // We need to re-request all previously loaded subsets to repopulate the data. this.truncateCleanup = this.collection.on(`truncate`, () => { - // A reload started its replay when it retired the run; the truncate of - // its first commit is part of that replay. - if (!this.collection._lifecycle.reloading) this.handleTruncate() + // A reload that replays on-demand demand started its replay when it + // retired the run; the truncate of its first commit is part of it. + if (this.reloadReplay !== this.truncateReplayState || !this.reloadReplay) + this.handleTruncate() }) this.collectionCleanup = this.collection._onCleanupStart(() => { this.handleCollectionCleanup() @@ -324,7 +327,16 @@ export class CollectionSubscription */ public retireSyncRun(): void { this.handleCollectionCleanup(true) - this.handleTruncate() + // Other demand keeps its retained rows published until the reload's + // first commit truncates them. + if ( + this.collection.config.syncMode === `on-demand` && + !this.defersAcquisition && + this.subsetDemands.length > 0 + ) { + this.handleTruncate() + this.reloadReplay = this.truncateReplayState + } } /** Detach logical demand from work owned by a discarded sync run. */ diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 5018721d4d..e6ed3962ed 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1398,6 +1398,33 @@ async function runHistory( } let effect: ReturnType<typeof createEffect> | undefined let effectErrors: Array<Error> = [] + // A direct subscriber with the initial state, on each source the shape + // reads. Within one Collection lifetime its applied rows are its + // Collection's rows at every step, also while a reload is in flight or + // after one fails. What a subscription that survives cleanup sees belongs + // to the subscription lifecycle oracle, so the check stops at cleanup + // start and a restart subscribes again. + const mirrors = { A: new Map<number, number>(), B: new Map<number, number>() } + const mirrored = { A: true, B: true } + let mirrorSubscriptions: Array<{ unsubscribe: () => void }> = [] + const mirror = () => { + for (const subscription of mirrorSubscriptions) subscription.unsubscribe() + mirrorSubscriptions = shapeSources[shape].map((name) => { + mirrors[name] = new Map() + mirrored[name] = true + return sources[name].collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = change.key + if (change.type === `delete`) mirrors[name].delete(key) + else mirrors[name].set(key, (change.value as Row).tag ?? 0) + } + }, + { includeInitialState: true }, + ) + }) + } + mirror() let listenersBeforeEffect = 0 const listenersOn = (collection: Collection<any, any>) => [ @@ -1518,6 +1545,7 @@ async function runHistory( sources.A.collection.cleanup(), sources.B.collection.cleanup(), ]) + mirror() begin() } else if (shapeSources[shape].includes(step.source)) { const params = sources[step.source].params @@ -1617,6 +1645,12 @@ async function runHistory( expect(tagsOf(collection.toArray), `${label}: ${name} rows`).toEqual( sortedTags(source.rows), ) + if (source.cleaning || source.retired) mirrored[name] = false + if (mirrored[name]) + expect( + sortedTags(mirrors[name]), + `${label}: ${name} direct subscriber rows`, + ).toEqual(sortedTags(source.rows)) if (source.gate === `crashed`) continue expect( sortedTags( @@ -1698,6 +1732,7 @@ async function runHistory( } finally { console.error = consoleError stop() + for (const subscription of mirrorSubscriptions) subscription.unsubscribe() await settleCleanups() await effect?.dispose() await query.cleanup() @@ -3970,7 +4005,13 @@ type LazyStep = } // One load the query requested, as the adapter saw it. -type LazyLoad = { serial: number; keys: Set<number>; replay: boolean } +// `fromReplay`: the replay itself made the request, not a demand change. +type LazyLoad = { + serial: number + keys: Set<number> + replay: boolean + fromReplay?: boolean +} type LazyModel = { gate: `loading` | `ready` | `error` | `crashed` @@ -4133,8 +4174,10 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.source.stopped = null // The retired run's loads no longer apply, and its truncate drops the // loaded children until the replay loads them again. - for (const { serial, keys } of model.pending) - model.retiredInFlight.set(serial, keys) + // Only the query's own loads hold it; a replay's loads stop when a + // newer replay replaces them. + for (const { serial, keys, fromReplay } of model.pending) + if (!fromReplay) model.retiredInFlight.set(serial, keys) // A replay this reload discards reports nothing. model.replayFailedKeys = new Set() model.pending = [] @@ -4297,6 +4340,7 @@ async function runLazyHistory( serial: request.serial, keys: request.keys, replay: model.replayActive && (replayMade || !request.thrown), + fromReplay: replayMade, } model.requested.set(load.serial, load.keys) model.pending.push(load) @@ -4651,6 +4695,19 @@ describe(`lazy join demand grammar`, () => { ], { honorsAbort: false }, )) + it(`a second reload's replay replaces the first replay's loads`, () => + runLazyHistory( + [ + { kind: `parent`, key: 2 }, + { kind: `reload` }, + { kind: `reload` }, + { kind: `unparent`, key: 1 }, + { kind: `settle-first-ok` }, + { kind: `settle-last-ok` }, + { kind: `settle-first-fail` }, + ], + { honorsAbort: false }, + )) it(`a first load waits for a source reload's whole replay`, () => runLazyHistory( [ From 6b4e762cefbd64b3a68c31e67c845d18796e1433 Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Thu, 22 Oct 2026 16:03:47 -0600 Subject: [PATCH 100/106] refactor(db): name the health tree `collection.health` The tree describes how each node's sync is doing, healthy or not, so it is health, not errors. Its problems stay errors. - `collection.errors` -> `collection.health`, `errors:change` -> `health:change`, Effect `onErrorsChange` -> `onHealthChange`, the hooks' `errors` -> `health`, `ErrorTreeNode` -> `HealthNode`. - `error`, `failureReason`, `hasErrors`, `isSyncError`, `isLoadingError`, request errors, and `findProblems()` keep their names. - The glossary and docs say "health tree" and "health node". The API reference is regenerated. None of these names shipped, so the changeset has no migration rows for them. Co-authored-by: Isaac <no-reply@databricks.com> --- .changeset/source-health-channel.md | 12 +- docs/collections/electric-collection.md | 6 +- docs/collections/query-collection.md | 6 +- .../HEALTH-CANDIDATE.md | 18 +- .../designs/source-failure-and-health/PLAN.md | 42 +- .../SourceHealth.tla | 4 +- .../USER-DOCS-DRAFT.md | 38 +- .../error-surface/README.md | 8 +- .../error-surface/RULES.md | 2 +- .../error-surface/user-docs-draft.md | 48 +- docs/contributing/glossary.md | 14 +- docs/contributing/oracle-coverage.md | 6 +- .../reference/functions/injectLiveQuery.md | 14 +- docs/framework/angular/reference/index.md | 1 + .../interfaces/InjectLiveQueryFlags.md | 122 ++ .../interfaces/InjectLiveQueryResult.md | 101 +- .../InjectLiveQueryResultWithCollection.md | 110 +- ...veQueryResultWithSingleResultCollection.md | 102 +- .../InjectConditionalLiveQueryResult.md | 2 +- docs/framework/react/overview.md | 2 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../react/reference/functions/useLiveQuery.md | 1078 ++--------------- .../functions/useLiveSuspenseQuery.md | 40 +- .../UseLiveInfiniteQueryReturn.md | 6 - ...seLiveInfiniteQueryReturnWithCollection.md | 92 +- .../solid/reference/functions/useLiveQuery.md | 28 +- docs/framework/svelte/overview.md | 2 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../reference/functions/useLiveQuery.md | 32 +- .../interfaces/UseLiveQueryReturn.md | 60 +- .../UseLiveQueryReturnWithCollection.md | 60 +- .../ConditionalUseLiveQueryReturn.md | 2 +- .../UseLiveInfiniteQueryReturn.md | 6 - ...seLiveInfiniteQueryReturnWithCollection.md | 8 +- .../type-aliases/UseLiveQueryConfig.md | 2 +- docs/framework/vue/overview.md | 2 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../vue/reference/functions/useLiveQuery.md | 30 +- docs/framework/vue/reference/index.md | 1 + .../interfaces/UseLiveInfiniteQueryReturn.md | 68 +- ...seLiveInfiniteQueryReturnWithCollection.md | 88 +- .../reference/interfaces/UseLiveQueryFlags.md | 110 ++ .../interfaces/UseLiveQueryReturn.md | 102 +- .../UseLiveQueryReturnWithCollection.md | 98 +- ...veQueryReturnWithSingleResultCollection.md | 90 +- .../ConditionalUseLiveQueryReturn.md | 2 +- docs/guides/error-handling.md | 62 +- docs/guides/live-queries.md | 16 +- docs/guides/offline-transactions.md | 2 +- docs/guides/sqlite-persistence.md | 4 +- .../IR/functions/collectCollectionSources.md | 2 +- .../IR/functions/collectSourceRefs.md | 8 +- .../IR/functions/createResidualWhere.md | 2 +- .../namespaces/IR/functions/followRef.md | 2 +- .../namespaces/IR/functions/getFromSources.md | 2 +- .../IR/functions/getHavingExpression.md | 2 +- .../IR/functions/getWhereExpression.md | 2 +- .../IR/functions/isResidualWhere.md | 2 +- .../AggregateFunctionNotInSelectError.md | 4 +- .../CannotCombineEmptyExpressionListError.md | 4 +- .../classes/CollectionHasSubscribersError.md | 222 ++++ docs/reference/classes/CollectionImpl.md | 247 ++-- .../classes/CollectionInputNotFoundError.md | 4 +- .../classes/CollectionIsInErrorStateError.md | 4 +- .../classes/CollectionOperationError.md | 4 +- .../classes/CollectionPreloadAbortedError.md | 4 +- .../classes/CollectionReloadCrashedError.md | 218 ++++ .../CollectionReloadDuringSyncEntryError.md | 218 ++++ .../reference/classes/CollectionStateError.md | 3 + .../classes/DebounceCallDroppedError.md | 4 +- .../classes/DeleteKeyNotFoundError.md | 4 +- .../classes/DistinctRequiresSelectError.md | 4 +- .../classes/DuplicateAliasInSubqueryError.md | 4 +- docs/reference/classes/DuplicateKeyError.md | 4 +- .../classes/DuplicateKeySyncError.md | 4 +- .../classes/DuplicateTransactionIdError.md | 4 +- .../classes/EmptyReferencePathError.md | 4 +- .../classes/FnSelectWithGroupByError.md | 4 +- docs/reference/classes/GroupByError.md | 4 +- .../classes/HavingRequiresGroupByError.md | 4 +- .../InvalidCollectionStatusTransitionError.md | 4 +- .../reference/classes/InvalidJoinCondition.md | 4 +- .../InvalidJoinConditionLeftSourceError.md | 4 +- .../InvalidJoinConditionRightSourceError.md | 4 +- .../InvalidJoinConditionSameSourceError.md | 4 +- ...InvalidJoinConditionSourceMismatchError.md | 4 +- docs/reference/classes/InvalidKeyError.md | 4 +- docs/reference/classes/InvalidSourceError.md | 4 +- .../classes/InvalidSourceTypeError.md | 4 +- .../classes/InvalidStorageDataFormatError.md | 4 +- .../InvalidStorageObjectFormatError.md | 4 +- .../classes/InvalidWhereExpressionError.md | 4 +- .../classes/JoinCollectionNotFoundError.md | 4 +- .../JoinConditionMustBeEqualityError.md | 4 +- docs/reference/classes/JoinError.md | 4 +- .../classes/KeyUpdateNotAllowedError.md | 4 +- .../classes/LimitOffsetRequireOrderByError.md | 4 +- .../classes/LiveQueryObserverDisposedError.md | 4 +- .../LiveQueryWindowControllerDisposedError.md | 4 +- .../LoadSubsetOperationAbortedError.md | 4 +- .../classes/LocalStorageCollectionError.md | 4 +- .../classes/MissingAliasInputsError.md | 4 +- .../classes/MissingDeleteHandlerError.md | 4 +- docs/reference/classes/MissingHandlerError.md | 4 +- .../classes/MissingInsertHandlerError.md | 4 +- .../classes/MissingMutationFunctionError.md | 4 +- .../classes/MissingUpdateArgumentError.md | 4 +- .../classes/MissingUpdateHandlerError.md | 4 +- .../classes/NegativeActiveSubscribersError.md | 4 +- .../classes/NoKeysPassedToDeleteError.md | 4 +- .../classes/NoKeysPassedToUpdateError.md | 4 +- .../NoPendingSyncTransactionCommitError.md | 4 +- .../NoPendingSyncTransactionWriteError.md | 4 +- ...NonAggregateExpressionNotInGroupByError.md | 4 +- .../classes/OnMutateMustBeSynchronousError.md | 4 +- .../classes/OnlyOneSourceAllowedError.md | 4 +- .../PacedTransactionManualCommitError.md | 4 +- docs/reference/classes/QueryBuilderError.md | 4 +- .../classes/QueryCompilationError.md | 4 +- .../classes/QueryMustHaveFromClauseError.md | 4 +- docs/reference/classes/QueryOptimizerError.md | 4 +- .../classes/QueueCapacityExceededError.md | 4 +- docs/reference/classes/QueueDisposedError.md | 4 +- docs/reference/classes/SerializationError.md | 4 +- .../classes/SetWindowFindOneLimitError.md | 4 +- .../classes/SetWindowReentrancyError.md | 4 +- .../classes/SetWindowRequiresOrderByError.md | 4 +- docs/reference/classes/StorageError.md | 4 +- .../classes/StorageKeyRequiredError.md | 4 +- .../SubQueryMustHaveFromClauseError.md | 4 +- docs/reference/classes/SyncCleanupError.md | 4 +- .../classes/SyncQueueInvariantError.md | 4 +- .../SyncRowReusedWithoutPreviousValueError.md | 4 +- .../classes/SyncTransactionAbortedError.md | 4 +- .../SyncTransactionAlreadyCommittedError.md | 4 +- ...ncTransactionAlreadyCommittedWriteError.md | 4 +- .../classes/ThrottleCallDroppedError.md | 4 +- ...ransactionAlreadyCompletedRollbackError.md | 4 +- docs/reference/classes/TransactionError.md | 4 +- .../TransactionNotPendingCommitError.md | 4 +- .../TransactionNotPendingMutateError.md | 4 +- docs/reference/classes/UndefinedKeyError.md | 4 +- .../classes/UnhashableQueryIRError.md | 8 +- .../classes/UnknownExpressionTypeError.md | 4 +- .../reference/classes/UnknownFunctionError.md | 4 +- .../UnknownHavingExpressionTypeError.md | 4 +- .../reference/classes/UnsafeAliasPathError.md | 4 +- .../UnsupportedAggregateFunctionError.md | 4 +- .../classes/UnsupportedFnSelectResultError.md | 4 +- .../classes/UnsupportedFromTypeError.md | 4 +- .../classes/UnsupportedJoinSourceTypeError.md | 4 +- .../classes/UnsupportedJoinTypeError.md | 4 +- .../UnsupportedRootScalarSelectError.md | 4 +- .../classes/UpdateKeyNotFoundError.md | 4 +- .../assertLiveQueryWindowManyResult.md | 2 +- .../compareLiveQueryWindowDependencies.md | 2 +- docs/reference/functions/createCollection.md | 16 +- docs/reference/functions/createEffect.md | 21 +- .../functions/createLiveQueryObserver.md | 2 +- .../createLiveQueryWindowController.md | 2 +- .../functions/fetchNextLiveQueryWindowPage.md | 2 +- docs/reference/functions/findProblems.md | 24 + .../functions/getLiveQueryStatusFlags.md | 2 +- .../getLiveQueryWindowCollectionWarning.md | 2 +- .../functions/getLiveQueryWindowInputKind.md | 2 +- .../functions/getPersistedReadinessSource.md | 23 - .../functions/hasLiveQueryWindowLeases.md | 2 +- .../functions/isLiveQueryWindowCollection.md | 2 +- .../functions/liveQueryWindowMatches.md | 2 +- .../normalizeLiveQueryWindowPageSize.md | 2 +- .../functions/resolveLiveQueryWindowInput.md | 2 +- .../shouldPreserveLiveQueryWindowPageCount.md | 2 +- .../withCollectionSyncConfigCleanup.md | 2 +- .../withCollectionSyncConfigFactory.md | 2 +- docs/reference/index.md | 12 +- .../interfaces/BaseCollectionConfig.md | 30 +- docs/reference/interfaces/ChangeMessage.md | 12 +- docs/reference/interfaces/Collection.md | 263 ++-- docs/reference/interfaces/CollectionConfig.md | 32 +- .../interfaces/CollectionDependent.md | 62 + .../interfaces/CollectionErrorInfo.md | 62 + .../interfaces/CollectionIndexMetadata.md | 16 +- docs/reference/interfaces/CollectionLike.md | 12 +- .../CreateLiveQueryObserverOptions.md | 10 +- .../CreateLiveQueryWindowControllerOptions.md | 8 +- .../CurrentStateAsChangesOptions.md | 10 +- docs/reference/interfaces/Effect.md | 6 +- docs/reference/interfaces/EffectConfig.md | 54 +- docs/reference/interfaces/EffectContext.md | 6 +- docs/reference/interfaces/HealthNode.md | 114 ++ .../interfaces/IndexedDBCollectionConfig.md | 28 +- docs/reference/interfaces/InsertConfig.md | 6 +- .../reference/interfaces/LiveQueryObserver.md | 48 +- .../reference/interfaces/LiveQuerySnapshot.md | 91 +- .../interfaces/LiveQueryStatusFlags.md | 22 +- .../interfaces/LiveQueryWindowController.md | 14 +- .../interfaces/LiveQueryWindowSnapshot.md | 86 +- .../interfaces/LocalOnlyCollectionConfig.md | 24 +- .../LocalStorageCollectionConfig.md | 28 +- docs/reference/interfaces/OperationConfig.md | 6 +- .../interfaces/PersistedReadinessSource.md | 78 -- .../interfaces/SubscribeChangesOptions.md | 55 +- .../SubscribeChangesSnapshotOptions.md | 57 +- docs/reference/interfaces/SyncConfig.md | 45 +- docs/reference/interfaces/SyncMetadataApi.md | 8 +- .../classes/PowerSyncTransactor.md | 12 +- .../functions/queryCollectionOptions.md | 8 +- .../interfaces/QueryCollectionUtils.md | 94 +- .../functions/rxdbCollectionOptions.md | 2 +- docs/reference/type-aliases/ChangeListener.md | 2 +- .../ChangeMessageOrDeleteKeyMessage.md | 2 +- docs/reference/type-aliases/ChangesPayload.md | 2 +- docs/reference/type-aliases/CollectionBase.md | 2 +- .../CollectionConfigSingleRowOption.md | 2 +- .../type-aliases/CollectionStatus.md | 9 +- .../type-aliases/DeleteKeyMessage.md | 2 +- .../type-aliases/DeleteMutationFn.md | 2 +- .../type-aliases/DeleteMutationFnParams.md | 6 +- docs/reference/type-aliases/DeltaEvent.md | 2 +- docs/reference/type-aliases/DeltaType.md | 2 +- .../type-aliases/EffectQueryInput.md | 2 +- docs/reference/type-aliases/InputRow.md | 2 +- .../type-aliases/InsertMutationFn.md | 2 +- .../type-aliases/InsertMutationFnParams.md | 6 +- .../type-aliases/KeyedNamespacedRow.md | 2 +- docs/reference/type-aliases/KeyedStream.md | 2 +- .../type-aliases/LiveQueryCollectionUtils.md | 2 +- .../type-aliases/LiveQueryObserverListener.md | 2 +- .../type-aliases/LiveQueryPersistedStatus.md | 10 - .../type-aliases/LiveQueryWindowCollection.md | 2 +- .../type-aliases/LiveQueryWindowInputKind.md | 2 +- docs/reference/type-aliases/MakeOptional.md | 2 +- .../type-aliases/MaybeSingleResult.md | 4 +- .../type-aliases/NamespacedAndKeyedStream.md | 2 +- docs/reference/type-aliases/NamespacedRow.md | 2 +- .../reference/type-aliases/NonSingleResult.md | 4 +- .../type-aliases/OptimisticChangeMessage.md | 2 +- .../PersistedReadinessSnapshot.md | 24 - .../ResolvedLiveQueryWindowInput.md | 2 +- docs/reference/type-aliases/ResultStream.md | 2 +- docs/reference/type-aliases/SingleResult.md | 4 +- .../type-aliases/SourceClauseContext.md | 2 +- docs/reference/type-aliases/StandardSchema.md | 2 +- .../type-aliases/StandardSchemaAlias.md | 2 +- docs/reference/type-aliases/SyncMode.md | 2 +- .../SyncPersistenceCapabilityV1.md | 20 +- .../SyncPersistenceKeySetEvidence.md | 4 +- .../SyncPersistenceScanOptions.md | 4 +- .../type-aliases/SyncPersistenceScannedRow.md | 8 +- .../type-aliases/UpdateMutationFn.md | 2 +- .../type-aliases/UpdateMutationFnParams.md | 6 +- docs/reference/type-aliases/WritableDeep.md | 2 +- .../variables/PERSISTED_READINESS.md | 14 - .../angular-db/skills/angular-db/SKILL.md | 2 +- packages/angular-db/src/index.ts | 18 +- packages/angular-db/tests/conformance.test.ts | 4 +- packages/db-sqlite-persistence-core/README.md | 2 +- .../tests/persisted-oracle.test.ts | 6 +- .../tests/persisted-readiness-oracle.test.ts | 6 +- packages/db/e2e/indexed-db/browser.ts | 2 +- packages/db/mangle-cache.json | 8 +- .../references/query-adapter.md | 2 +- .../db/skills/db-core/custom-adapter/SKILL.md | 4 +- packages/db/src/collection/events.ts | 16 +- .../{error-tree.ts => health-tree.ts} | 49 +- packages/db/src/collection/index.ts | 20 +- packages/db/src/collection/lifecycle.ts | 34 +- packages/db/src/live-query-observer.ts | 48 +- .../db/src/live-query-window-controller.ts | 12 +- packages/db/src/query/effect.ts | 40 +- packages/db/src/query/live/ARCHITECTURE.md | 18 +- .../query/live/collection-config-builder.ts | 16 +- packages/db/src/query/pooled-live-query.ts | 12 +- packages/db/src/types.ts | 8 +- .../collection-cleanup-restart-oracle.test.ts | 10 +- .../tests/conformance-disabled-laws.test.ts | 6 +- packages/db/tests/conformance/contract.ts | 8 +- .../db/tests/conformance/disabled-laws.ts | 2 +- .../conformance/infinite-suite-oracle.ts | 2 +- packages/db/tests/conformance/suite.ts | 14 +- .../cross-tab-boundary-oracle.test.ts | 4 +- packages/db/tests/indexed-db/recorder.ts | 6 +- .../tests/indexed-db/transport-oracle.test.ts | 4 +- .../live-query-window-controller.test.ts | 2 +- .../query/includes-temporal-oracle.test.ts | 2 +- .../tests/query/live-query-collection.test.ts | 2 +- .../tests/query/subset-error-matrix.test.ts | 2 +- .../db/tests/source-health-oracle.test.ts | 194 +-- .../tests/electric-oracle.property.test.ts | 4 +- .../tests/on-demand-sync.test.ts | 8 +- packages/query-db-collection/src/query.ts | 4 +- .../load-subset-lifecycle-oracle.test.ts | 22 +- .../tests/ownership-lifecycle.oracle.test.ts | 4 +- .../query-db-collection/tests/query.test.ts | 14 +- packages/react-db/skills/react-db/SKILL.md | 4 +- packages/react-db/src/useLiveInfiniteQuery.ts | 8 +- packages/react-db/src/useLiveQuery.ts | 6 +- packages/react-db/src/useLiveSuspenseQuery.ts | 18 +- packages/react-db/tests/conformance.test.tsx | 2 +- packages/react-db/tests/useLiveQuery.test.tsx | 6 +- .../rxdb-db-collection/tests/rxdb.test.ts | 6 +- packages/solid-db/skills/solid-db/SKILL.md | 2 +- packages/solid-db/src/useLiveQuery.ts | 24 +- packages/solid-db/tests/conformance.test.tsx | 2 +- .../tests/suspense-after-readiness.test.tsx | 21 +- packages/svelte-db/skills/svelte-db/SKILL.md | 2 +- .../src/useLiveInfiniteQuery.svelte.ts | 4 +- packages/svelte-db/src/useLiveQuery.svelte.ts | 22 +- .../tests/conformance.svelte.test.ts | 2 +- .../tests/lifecycle-oracle.property.test.ts | 6 +- .../tests/trailbase.test.ts | 4 +- packages/vue-db/skills/vue-db/SKILL.md | 4 +- packages/vue-db/src/useLiveInfiniteQuery.ts | 12 +- packages/vue-db/src/useLiveQuery.ts | 16 +- packages/vue-db/tests/conformance.test.ts | 2 +- 315 files changed, 3636 insertions(+), 2957 deletions(-) create mode 100644 docs/framework/angular/reference/interfaces/InjectLiveQueryFlags.md create mode 100644 docs/framework/vue/reference/interfaces/UseLiveQueryFlags.md create mode 100644 docs/reference/classes/CollectionHasSubscribersError.md create mode 100644 docs/reference/classes/CollectionReloadCrashedError.md create mode 100644 docs/reference/classes/CollectionReloadDuringSyncEntryError.md create mode 100644 docs/reference/functions/findProblems.md delete mode 100644 docs/reference/functions/getPersistedReadinessSource.md create mode 100644 docs/reference/interfaces/CollectionDependent.md create mode 100644 docs/reference/interfaces/CollectionErrorInfo.md create mode 100644 docs/reference/interfaces/HealthNode.md delete mode 100644 docs/reference/interfaces/PersistedReadinessSource.md delete mode 100644 docs/reference/type-aliases/LiveQueryPersistedStatus.md delete mode 100644 docs/reference/type-aliases/PersistedReadinessSnapshot.md delete mode 100644 docs/reference/variables/PERSISTED_READINESS.md rename packages/db/src/collection/{error-tree.ts => health-tree.ts} (71%) diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md index 6c77bbddf5..18b6414879 100644 --- a/.changeset/source-health-channel.md +++ b/.changeset/source-health-channel.md @@ -13,10 +13,10 @@ '@tanstack/angular-db': minor --- -Collections now answer two questions separately: can the data be shown (`status`), and is it still kept up to date (`errors`). Once a query has shown data, a network or backend failure never takes it off the screen. +Collections now answer two questions separately: can the data be shown (`status`), and is it still kept up to date (`health`). Once a query has shown data, a network or backend failure never takes it off the screen. - `status` gains `crashed`: local storage broke (a closed IndexedDB connection, a failed SQLite persistence write or restore, a throwing sync function, or an invariant failure). Nothing recovers it in place, and `reload()` rejects. To recover, call `cleanup({ dependents: true })`, then start the Collection again (for example with `preload()`); it starts with no rows. It reaches every live query and Effect above it. `error` now means only "the first load failed". -- Every Collection has `collection.errors`, an error tree node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `errors` has one node per source alias, in query order, and keeps object identity while unchanged. `errors:change` fires when it changes. `findProblems(errors)` lists the nodes that have a problem, in query order. After a live query is ready, a failed request for more data (a page, a joined row, or a source reload's replay of its data) is an `error` on the query's own node; the source stays healthy. +- Every Collection has `collection.health`, a health node with `status`, `failureReason` (the adapter is still trying), `error` (the adapter stopped, or the first load failed), `reloading`, and `hasErrors`. A live query's `health` has one node per source alias, in query order, and keeps object identity while unchanged. `health:change` fires when it changes. `findProblems(health)` lists the nodes that have a problem, in query order. After a live query is ready, a failed request for more data (a page, a joined row, or a source reload's replay of its data) is an `error` on the query's own node; the source stays healthy. - `collection.reload()` starts a new sync run and keeps the rows, status, and error on screen until the new run commits. On a live query it reloads every Collection below it that shows an `error`, through nested live queries, then the query itself when its own node has an `error`, including a failed request for more data. It rejects on a `crashed` Collection. When the cleanup of the sync run that a reload retired rejects, the Collection crashes, also if the rejection comes later. A query in `error` stays there until its `reload()`. - `cleanup()` rejects with `CollectionHasSubscribersError` while a live query with subscribers or an Effect reads the Collection. `cleanup({ dependents: true })` ends them first. `collection.subscribers` lists every dependent. - Persisted Collections have one status. With `initialRender: { strategy: 'network-first' }`, restored rows make the Collection `ready` once the deadline passes or the remote fails; an empty restore stays `loading`. @@ -26,13 +26,13 @@ Breaking changes and migration: | Before | After | | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | hook `isError` | `isLoadingError` (first load failed) or `isCrashed` | -| — | `isSyncError`: `ready` and the error tree has a problem | +| — | `isSyncError`: `ready` and the health tree has a problem | | `status: 'error'` after a crash | `status: 'crashed'` | | adapter `markError(error)` | `fail(error)`. After readiness it sets the node's `error` and keeps the rows. Later calls throw. | | `cleanup()` then `preload()` to recover | `reload()` | | `cleanup()` while live queries or Effects read the Collection | rejects; unmount first or pass `{ dependents: true }` | -| `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()`, `PERSISTED_READINESS` | `status` and `errors` | -| query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.errors`, `reload()` | -| infinite-query hooks' `error` (React, Vue, Svelte) | the root node's `error` in `errors`; `fetchNextPage()` still resolves | +| `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()`, `PERSISTED_READINESS` | `status` and `health` | +| query-db `utils.lastError`, `utils.isError`, `utils.errorCount`, `utils.clearError()` | `collection.health`, `reload()` | +| infinite-query hooks' `error` (React, Vue, Svelte) | the root node's `error` in `health`; `fetchNextPage()` still resolves | Custom sync adapters: keep the run's readiness in your `sync` function. A reload calls `sync` again while `collection.status` still shows the previous run's status, and a reload replaces every row at the new run's first commit, so write a full snapshot then. `reportError()` and `reportHealthy()` throw before the first `markReady()`. `markReady()` after a failed first load throws. Calls after a crash do nothing. For an on-demand Collection, a reload aborts the retired run's `loadSubset` calls and asks the new run's `loadSubset` for the current demand as soon as `sync` returns. Honor the `signal`: a load that ignores the abort holds a query's first load until it settles. `useLiveSuspenseQuery` now throws the first load's original cause instead of a generic error. diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 40d43491fc..a59d1b7984 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -410,12 +410,12 @@ An eager or progressive resume cannot apply a partial update to an unknown row. The adapter rejects that batch, stops the stream, and records a reset so the next sync starts from a full snapshot. Before the Collection is ready, this fails the first load (`status: 'error'`); after it, the rows stay and the -source node shows an `error` in `collection.errors` and in each live query's -`errors` tree. This does not silently retry the failed stream; +source node shows an `error` in `collection.health` and in each live query's +`health` tree. This does not silently retry the failed stream; `collection.reload()` refetches. While the Electric stream retries a failed request, the Collection reports -nothing: it stays `loading` before its first up-to-date, and keeps its error tree +nothing: it stays `loading` before its first up-to-date, and keeps its health tree unchanged afterward. When the stream gives up, it calls `fail()`: a failed first load before readiness, or the source node's `error` after it. Complete updates from an explicit `replica: 'full'` stream remain valid. On-demand streams can observe updates outside their loaded subsets; unknown diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index da2d1a5f09..01da65caff 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -683,12 +683,12 @@ The collection provides these utility methods via `collection.utils`: - Returns the tracked Queries' `QueryObserverResult` values in tracked-key order - Preserves an `undefined` slot if a tracked Query is removed while the refetch starts -Failures appear in the error tree. Before the first successful result, a +Failures appear in the health tree. Before the first successful result, a failed fetch fails the first load (`status: 'error'`). After the Collection is ready, a failed fetch or result application for any Query it observes sets the node's `failureReason`, because TanStack Query fetches again, and the next -successful application clears it. Read it from `collection.errors` or a live -query's `errors` tree. To retry a failed first load, call +successful application clears it. Read it from `collection.health` or a live +query's `health` tree. To retry a failed first load, call `collection.reload()`. ## Direct Writes diff --git a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md index dff0db1fe6..c22e4989a1 100644 --- a/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md +++ b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md @@ -1,8 +1,8 @@ -# Candidate: the source health channel and live-query error tree +# Candidate: the source health channel and live-query health tree > **Superseded (2026-10-16).** The error surface was redesigned: status > `crashed`, node `failureReason`/`error`, `fail()`, `reload()`, and -> `collection.errors`. Read `error-surface/README.md` and `error-surface/RULES.md`, +> `collection.health`. Read `error-surface/README.md` and `error-surface/RULES.md`, > the shipped `docs/guides/error-handling.md`, and > `packages/db/src/query/live/ARCHITECTURE.md` laws 14, 16, and 17. @@ -31,7 +31,7 @@ guides, and `packages/db/src/query/live/ARCHITECTURE.md` laws 16 and 17. disposed. - H10 A persisted collection keeps serving local rows when upstream sync fails after ready; health says upstream is degraded. -- H11 `useLiveQuery` returns an `errors` structure describing the tree of +- H11 `useLiveQuery` returns an `health` structure describing the tree of collections it depends on. It always has the same shape, shows whether each node is in error, and carries detailed error info when in error, so apps can parse it. @@ -101,20 +101,20 @@ type CollectionErrorInfo = { `phase: 'initial'` mirrors a gate `error`; `phase: 'sync'` is a post-ready health report; `phase: 'request'` is a subset load failure (H6). -### The error tree +### The health tree -Every live-query Collection exposes `errors`, and every framework hook returns +Every live-query Collection exposes `health`, and every framework hook returns it beside `data`: ```ts -type ErrorTreeNode = { +type HealthNode = { collectionId: string kind: 'source' | 'live-query' status: CollectionStatus // the node's gate isError: boolean // this node: gate error, health error, or request error hasErrors: boolean // this node or any descendant error: CollectionErrorInfo | null // detail when isError - sources: Array<ErrorTreeNode> // dependencies; [] for a base source + sources: Array<HealthNode> // dependencies; [] for a base source } ``` @@ -127,11 +127,11 @@ type ErrorTreeNode = { - A post-ready request failure (H6) sets `error` with `phase: 'request'` on the live-query node that issued the request. The source stays healthy. - `isError` on the hook result keeps its current meaning: the gate is - `error`. `errors.hasErrors` reports anything anywhere in the tree. + `error`. `health.hasErrors` reports anything anywhere in the tree. ### Effects -`createEffect({ onSourceHealth?: (errors: ErrorTreeNode) => void })`. After +`createEffect({ onSourceHealth?: (health: HealthNode) => void })`. After the first ready a health change calls it; the Effect is not disposed. Before the first ready, a gate error still disposes the Effect through `onSourceError`. diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 46529ddcf8..68ac6dbf9e 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -2,7 +2,7 @@ > **Superseded (2026-10-16).** The error surface was redesigned: status > `crashed`, node `failureReason`/`error`, `fail()`, `reload()`, and -> `collection.errors`. Read `error-surface/README.md` and `error-surface/RULES.md`, +> `collection.health`. Read `error-surface/README.md` and `error-surface/RULES.md`, > the shipped `docs/guides/error-handling.md`, and > `packages/db/src/query/live/ARCHITECTURE.md` laws 14, 16, and 17. @@ -31,7 +31,7 @@ Branch: `feat/source-health-channel` (worktree 5. **`status` is only the initial gate.** After the first ready, a sync failure is metadata on the Collection. The data stays, even if it grows stale. -6. **`useLiveQuery` returns an `errors` tree** for every collection the query +6. **`useLiveQuery` returns an `health` tree** for every collection the query depends on. It always has the same shape, shows whether each node is in error, and carries detailed error info, so apps can parse it. 7. A buggy source or an app with endless retries can still look stuck. That is @@ -59,7 +59,7 @@ names and shapes. - **Collection surface.** `collection.health` and a `health:change` event. - **Error info.** `{ phase: 'initial' | 'sync' | 'request', final, name, message, code?, at, cause }`. Everything except `cause` is JSON-safe. -- **Error tree.** `ErrorTreeNode = { alias, collectionId, kind: 'source' | +- **Health tree.** `HealthNode = { alias, collectionId, kind: 'source' | 'live-query' | 'include', status, isError, hasErrors, error, instances?, sources }`. - One node per query alias, in query order, fixed by the query definition @@ -74,11 +74,11 @@ names and shapes. Collection readiness sets `phase: 'request'` on the live-query node that issued it. The Collection stays healthy. It clears when that demand later settles or is released. -- **Hooks.** Return `errors` beside `data`. `isError` keeps meaning "the - initial gate is `error`". Docs lead with `errors.hasErrors` for stale data. -- **Effects.** `onHealthChange(errors)` after Collection readiness; no disposal. +- **Hooks.** Return `health` beside `data`. `isError` keeps meaning "the + initial gate is `error`". Docs lead with `health.hasErrors` for stale data. +- **Effects.** `onHealthChange(health)` after Collection readiness; no disposal. Initial gate errors still dispose through `onSourceError`. -- **SSR.** `errors` is not dehydrated; the client builds its own tree. +- **SSR.** `health` is not dehydrated; the client builds its own tree. ## 3. Work breakdown @@ -96,7 +96,7 @@ names and shapes. after `status:change` when both change (the ordering is still to be checked). - [ ] `types.ts`: `SyncConfig` params, `CollectionErrorInfo`, - `ErrorTreeNode`, and the `Collection.health` and `errors` types. + `HealthNode`, and the `Collection.health` and `health` types. - [ ] `markError` after the first ready: forward to `reportError(error, { final: true })` and warn in development. - [ ] `query/live/collection-config-builder.ts`: @@ -106,7 +106,7 @@ names and shapes. status-based healing; - [ ] post-readiness Collection health does not change the live query's status or rows; - - [ ] build and maintain the `errors` tree: per-alias nodes, include + - [ ] build and maintain the `health` tree: per-alias nodes, include roll-ups, nested live-query relay, and request errors; - [ ] `failDemand` and `recordSubsetError`: before ready, terminal (gate); after ready, a request error on the live-query node, cleared on @@ -115,7 +115,7 @@ names and shapes. for every rejected load (#2129 review C8). Do not detach demand for recovery. - [ ] `query/effect.ts`: `onHealthChange`; no disposal after Collection readiness. -- [ ] `live-query-adapter.ts` and `live-query-observer.ts`: add `errors` to +- [ ] `live-query-adapter.ts` and `live-query-observer.ts`: add `health` to the snapshot and status flags, so every framework adapter gets it from one place. Keep `isError` as the gate. - [ ] `query/live/pooled-live-query*`: decide and implement health for @@ -146,7 +146,7 @@ names and shapes. - [ ] **TrailBase** (`trailbase-db-collection/src/trailbase.ts`, 4 sites): post-readiness stream failures call `reportError`. Check them against the #2126 fix. - - [ ] A stream that ends, whether it closes or errors, reports + - [ ] A stream that ends, whether it closes or health, reports `reportError(error, { final: true })` after Collection readiness. The adapter does not resubscribe. The `trailbase` SDK (0.10.0) has no reconnect or resume: `subscribe` is one `fetch` stream, and @@ -177,19 +177,19 @@ names and shapes. ### 3.3 Framework hooks -- [ ] React, Vue, Solid, Svelte, and Angular return `errors` from every +- [ ] React, Vue, Solid, Svelte, and Angular return `health` from every live-query hook, including infinite-query hooks and `useLiveSuspenseQuery`. - [ ] Suspense: throws only for the initial gate, never for post-ready health. -- [ ] Decide whether `errors` keeps stable node identity for memoization +- [ ] Decide whether `health` keeps stable node identity for memoization (open question). ### 3.4 Tests and oracles - [ ] Flip the #1751 healing tests on purpose: each one that asserts `error` → `ready` becomes "stays `error`; recovery needs a restart". - Search `collection-errors.test.ts`, live-query recovery tests, and + Search `collection-health.test.ts`, live-query recovery tests, and query-db and PowerSync recovery tests. - [ ] A new oracle owner for the health channel, from `SourceHealth.tla`'s properties: stable shape, mirroring through nested live queries, only @@ -205,7 +205,7 @@ names and shapes. `source-ready` after `source-error` in one sync run is illegal, so those histories become "restart, then recover". - [ ] Shared framework conformance suite (`packages/db/tests/conformance/`): - a health scenario, so all five drivers check `errors` the same way. + a health scenario, so all five drivers check `health` the same way. - [ ] Adapter witnesses: each adapter reports post-ready failures through health and keeps its rows. Electric adds SDK give-up cases through the real SDK (missing headers, retry exhaustion). @@ -226,11 +226,11 @@ names and shapes. `USER-DOCS-DRAFT.md` (stay silent while retrying; `markError` only when giving up before Collection readiness; `reportError` and `reportHealthy` after readiness; never drop rows). -- [ ] `docs/guides/live-queries.md`: the `errors` tree and the - `isError` / `errors.hasErrors` callout. +- [ ] `docs/guides/live-queries.md`: the `health` tree and the + `isError` / `health.hasErrors` callout. - [ ] `docs/guides/sqlite-persistence.md`, `docs/guides/offline-transactions.md`: upstream Collection health against `persistedError`. -- [ ] `docs/errors.md`: the `markReady`-after-`markError` contract error, +- [ ] `docs/health.md`: the `markReady`-after-`markError` contract error, and any new error code. - [ ] `docs/collections/electric-collection.md`: `onError` retries are silent; a give-up is final; post-readiness health. @@ -258,7 +258,7 @@ names and shapes. - an initial error no longer heals; recover with `cleanup()` and `preload()`; - after Collection readiness, failures no longer move a Collection or a live - query to `error`; read `errors`; + query to `error`; read `health`; - adapter authors: `reportError` and `reportHealthy`; - `markReady()` after `markError()` now throws. - [ ] PR body: the decisions above, the design, the evidence, and the @@ -271,7 +271,7 @@ names and shapes. update here. 3. Core lifecycle and the health channel, with the health oracle first (RED, then GREEN). -4. Live queries: the gate for every shape, then the error tree, then +4. Live queries: the gate for every shape, then the health tree, then request errors. 5. Effects, then the observer and status flags, then the hooks and the conformance suite. @@ -284,7 +284,7 @@ names and shapes. tick, after it). Model-check it before relying on it. - How follower tabs learn the leader's upstream health. - Health for pooled live queries that share sources. -- Stable node identity in `errors` for memoization. +- Stable node identity in `health` for memoization. - Which semver bump the packages get, given the 0.x line. - Whether the shipped package skills are in scope for edits. diff --git a/docs/contributing/designs/source-failure-and-health/SourceHealth.tla b/docs/contributing/designs/source-failure-and-health/SourceHealth.tla index f078ac0857..d9ef5c2fb6 100644 --- a/docs/contributing/designs/source-failure-and-health/SourceHealth.tla +++ b/docs/contributing/designs/source-failure-and-health/SourceHealth.tla @@ -19,13 +19,13 @@ CONSTANTS FailureClearsRows \* a post-ready failure discards visible rows Sources == {"A", "B"} -Nodes == {"A", "B", "L1"} \* the nodes in Top's error tree +Nodes == {"A", "B", "L1"} \* the nodes in Top's health tree VARIABLES status, \* gate per source: "loading" | "ready" | "error" health, \* per source, after ready: "healthy" | "failed" rows, \* per source: visible rows exist - tree, \* Top's error tree: node -> "ok" | "error"; DOMAIN is the shape + tree, \* Top's health tree: node -> "ok" | "error"; DOMAIN is the shape topStatus, \* Top's gate: "loading" | "ready" | "error" topRows, \* Top's visible rows reported \* per source: the source's latest report was a failure diff --git a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md index 20f15fba42..7f24cb650e 100644 --- a/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md +++ b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md @@ -2,7 +2,7 @@ > **Superseded (2026-10-16).** The error surface was redesigned: status > `crashed`, node `failureReason`/`error`, `fail()`, `reload()`, and -> `collection.errors`. Read `error-surface/README.md` and `error-surface/RULES.md`, +> `collection.health`. Read `error-surface/README.md` and `error-surface/RULES.md`, > the shipped `docs/guides/error-handling.md`, and > `packages/db/src/query/live/ARCHITECTURE.md` laws 14, 16, and 17. @@ -14,14 +14,14 @@ guides. --- -## Guide: errors, readiness, and stale data +## Guide: health, readiness, and stale data A Collection answers two separate questions: | Question | Where to look | Changes when | | --- | --- | --- | | Can I show this data yet? | `status`, `isReady`, `isError` | Only during the initial load of a sync run | -| Is this data still being kept up to date? | `errors` (the error tree) | Any time a source reports a problem or recovers | +| Is this data still being kept up to date? | `health` (the health tree) | Any time a source reports a problem or recovers | ### The initial load @@ -42,25 +42,25 @@ await todosQuery.preload() Once the data has loaded, it stays. If the source later loses its connection or a refresh fails, `status` stays `ready` and your rows stay on screen, even as -they get older. The problem appears in `errors` instead. +they get older. The problem appears in `health` instead. > **Important:** `isError` only describes the initial load. To show a -> "data may be out of date" banner, check `errors.hasErrors`. +> "data may be out of date" banner, check `health.hasErrors`. ```tsx function TaskList() { - const { data, isLoading, isError, errors } = useLiveQuery((q) => + const { data, isLoading, isError, health } = useLiveQuery((q) => q .from({ task: tasks }) .join({ user: users }, ({ task, user }) => eq(task.assigneeId, user.id)), ) if (isLoading) return <Spinner /> - if (isError) return <LoadFailed error={errors.error ?? errors.sources[0]?.error} /> + if (isError) return <LoadFailed error={health.error ?? health.sources[0]?.error} /> return ( <> - {errors.hasErrors && <StaleBanner errors={errors} />} + {health.hasErrors && <StaleBanner health={health} />} {data.map((row) => ( <TaskRow key={row.task.id} {...row} /> ))} @@ -69,14 +69,14 @@ function TaskList() { } ``` -### The error tree +### The health tree -Every live query result includes `errors`. It always has the same shape for a +Every live query result includes `health`. It always has the same shape for a given query: one node for the live query, and one node for each source in the query, in query order. Healthy nodes are present too, with `isError: false`. ```ts -type ErrorTreeNode = { +type HealthNode = { alias: string // the query alias, or "root" collectionId: string kind: 'source' | 'live-query' | 'include' @@ -85,7 +85,7 @@ type ErrorTreeNode = { hasErrors: boolean // this node or any node below it error: CollectionErrorInfo | null instances?: { total: number; inError: number } // include nodes only - sources: Array<ErrorTreeNode> + sources: Array<HealthNode> } type CollectionErrorInfo = { @@ -153,15 +153,15 @@ A collection used twice in one query, for example a self-join, appears once per alias. An include has one node for all of its per-row child collections, with `instances` counting how many are in error. -`errors` is plain data apart from `cause`, so you can log or send it: +`health` is plain data apart from `cause`, so you can log or send it: ```ts useEffect(() => { - if (errors.hasErrors) reportToMonitoring(JSON.parse(JSON.stringify(errors))) -}, [errors]) + if (health.hasErrors) reportToMonitoring(JSON.parse(JSON.stringify(health))) +}, [health]) ``` -Server-side rendering does not transfer `errors`. The client builds its own +Server-side rendering does not transfer `health`. The client builds its own tree from its own sync. ### Effects @@ -175,8 +175,8 @@ createEffect({ onBatch: (events) => { /* ... */ }, - onHealthChange: (errors) => { - if (errors.hasErrors) pauseAutomations() + onHealthChange: (health) => { + if (health.hasErrors) pauseAutomations() else resumeAutomations() }, }) @@ -253,5 +253,5 @@ warning. when both change. This is proposed, not checked. - Follower tabs: how a follower learns that the leader's upstream failed. - Pooled live queries that share sources. -- Whether `errors` should be a new object on every change or keep stable +- Whether `health` should be a new object on every change or keep stable node identities for memoization. diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/README.md b/docs/contributing/designs/source-failure-and-health/error-surface/README.md index c6031c116d..9c3d6aadb9 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/README.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/README.md @@ -1,7 +1,7 @@ # Error surface (2026-10-16) This folder supersedes the health-channel surface in `../HEALTH-CANDIDATE.md` and the -`phase` / `final` error tree. The maintainer chose it after a Field Lab study of how other +`phase` / `final` health tree. The maintainer chose it after a Field Lab study of how other libraries separate loading errors from errors after data is shown. - `RULES.md`: the frozen rules (grammar v4) and invariants. This is the contract the @@ -18,10 +18,10 @@ Decisions, in short: 1. `status` answers "can the data be shown": `idle | loading | ready | error | crashed | cleaned-up`, one flag per value (`isLoadingError`, `isCrashed`, ...). After a query has shown data, only a crash changes its status. `isError` is deprecated. -2. `errors` answers "is the data still kept current". Each node carries `failureReason` +2. `health` answers "is the data still kept current". Each node carries `failureReason` (the adapter still tries) or `error` (it stopped), like TanStack Query. `isSyncError` is - `status === 'ready'` and any problem in the tree. Every Collection has `errors` and - `errors:change`; the `health` names are gone. + `status === 'ready'` and any problem in the tree. Every Collection has `health` and + `health:change`; the `errors` names are gone. 3. Adapter calls: `markReady()`, `reportError(e)`, `fail(e)`, `reportHealthy()`. A call that does not fit the state machine throws; `reportHealthy()` with nothing wrong is a no-op. 4. `reload()` starts a new sync run like a sync-side truncate: nothing visible changes until diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md b/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md index 494dfdc588..f81c8bf86a 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/RULES.md @@ -10,7 +10,7 @@ restore rows from local disk storage and also sync with a remote backend. - One boolean per status: isIdle, isLoading, isReady, isLoadingError (status 'error'), isCrashed, isCleanedUp. `isError` is deprecated. - `isSyncError`: status is 'ready' and the tree holds any `failureReason` or `error`. -- `errors`: a tree, one node per query alias plus a root node for the query. Each node: +- `health`: a tree, one node per query alias plus a root node for the query. Each node: { alias, kind: 'source'|'live-query'|'include', status, hasErrors, error | null, sources }. A source node carries at most one of: - `failureReason`: the latest problem while the adapter keeps trying (like TanStack Query's diff --git a/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md index d396701ca7..39a21407ae 100644 --- a/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md +++ b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md @@ -8,9 +8,9 @@ DRAFT (2026-10-16) of the loading and sync sections of docs/guides/error-handlin design grammar v4. It replaces "Query Collection Error Tracking", "Incremental Subset Load Errors", "Collection Status and Error States", and "Collection Cleanup and Restart". The mutation, transaction, and error-class sections stay as they are. -Decisions applied: tree helper, query-level reload, `collection.errors` on every +Decisions applied: tree helper, query-level reload, `collection.health` on every Collection, `subscribers` and `cleanup({ dependents: true })`, adapter calls that throw -outside the state machine, and no remaining "health" names. +outside the state machine, and no remaining "errors" names for the tree. --> ## Loading and sync errors @@ -18,10 +18,10 @@ outside the state machine, and no remaining "health" names. An app that shows synced data asks two questions: 1. **Can I show the data?** Read `status`. -2. **Is the data on screen still being kept up to date?** Read `isSyncError` and `errors`. +2. **Is the data on screen still being kept up to date?** Read `isSyncError` and `health`. TanStack DB answers them separately. Once a query has shown data, a network or backend -failure never takes the data off the screen. It shows up in `errors`, and the rows stay. +failure never takes the data off the screen. It shows up in `health`, and the rows stay. ```tsx function TaskList() { @@ -31,7 +31,7 @@ function TaskList() { isLoadingError, isCrashed, isSyncError, - errors, + health, collection, } = useLiveQuery((q) => q @@ -46,7 +46,7 @@ function TaskList() { return ( <> {isSyncError && ( - <SyncBanner errors={errors} onReload={() => collection.reload()} /> + <SyncBanner health={health} onReload={() => collection.reload()} /> )} {data.map((row) => ( <TaskRow key={row.task.id} {...row} /> @@ -70,7 +70,7 @@ function TaskList() { Two rules make `status` predictable: - **After a query has shown data, only a crash changes its status.** A dropped connection, - a failed refetch, or a backend that stops answering appears in `errors`. `isLoadingError` + a failed refetch, or a backend that stops answering appears in `health`. `isLoadingError` therefore always means "nothing to show yet". - **A query takes the worst status of its sources until it is ready.** If any source fails its first load, the query's status is `error`. @@ -78,9 +78,9 @@ Two rules make `status` predictable: `isError` is deprecated. It is `true` for `error` and `crashed`. Use `isLoadingError` and `isCrashed`, which need different handling. -### Sync errors: the `errors` tree +### Sync errors: the `health` tree -`errors` has one node for the query itself and one for each source in the query, in query +`health` has one node for the query itself and one for each source in the query, in query order. The tree's shape is fixed by the query, so you can render, log, or send it to monitoring. @@ -119,17 +119,17 @@ token gives a `failureReason` on every refetch until the user signs in. Use `cod `cause` for cases like this: ```ts -function SyncBanner({ errors, onReload }: { errors: ErrorTreeNode; onReload: () => void }) { - const [first] = findProblems(errors) // nodes with a problem, in query order +function SyncBanner({ health, onReload }: { health: HealthNode; onReload: () => void }) { + const [first] = findProblems(health) // nodes with a problem, in query order if (first?.failureReason?.code === '401') return <SignInAgain /> if (first?.error) return <UpdatesStopped onReload={onReload} /> return <Reconnecting /> } ``` -`findProblems(errors)` walks the tree and returns every node with a `failureReason` or an +`findProblems(health)` walks the tree and returns every node with a `failureReason` or an `error`, in query order, each with its `alias` path. Use it for the message on a failure -screen too: when `isLoadingError` is true, `findProblems(errors)[0].error` is the first +screen too: when `isLoadingError` is true, `findProblems(health)[0].error` is the first load failure that stopped the query. `onReload` here calls the query's `reload()`, which reloads every source that has an @@ -142,12 +142,12 @@ need a live query: ```ts todos.status // 'loading' | 'ready' | 'error' | 'crashed' | ... -todos.errors // one node: { status, failureReason, error, reloading, ... } -todos.on('errors:change', ({ errors }) => report(errors)) +todos.health // one node: { status, failureReason, error, reloading, ... } +todos.on('health:change', ({ health }) => report(health)) await todos.reload() ``` -A live query is a Collection too; its `errors` is the whole tree. +A live query is a Collection too; its `health` is the whole tree. ### Failed requests for more data @@ -158,10 +158,10 @@ the data loads, or when the query no longer needs it, for example after the user back. ```tsx -const { data, errors, fetchNextPage } = useLiveInfiniteQuery(/* ... */) +const { data, health, fetchNextPage } = useLiveInfiniteQuery(/* ... */) { - errors?.error && ( + health?.error && ( <button onClick={() => fetchNextPage()}>Retry loading more</button> ) } @@ -234,7 +234,7 @@ A persisted Collection restores its rows from local storage before the remote sy - **Restored rows open the Collection.** If local storage holds rows from an earlier sync, the Collection becomes `ready` with them, even if the remote has not connected yet. Remote - problems after that appear in `errors` like any other sync problem. + problems after that appear in `health` like any other sync problem. - **A failed remote waits for the restore.** If the remote fails before the restore finishes, the Collection waits: restored rows make it `ready` with an `error` on its node; no stored rows make it `error`. @@ -249,14 +249,14 @@ A persisted Collection restores its rows from local storage before the remote sy An Effect ends when a source it reads fails its first load or crashes; it calls `onSourceError(error)`. After its initial load, any other problem calls -`onErrorsChange(errors)` with the same tree a live query returns, and the Effect keeps +`onHealthChange(health)` with the same tree a live query returns, and the Effect keeps running. ### Suspense and error boundaries (React) `useLiveSuspenseQuery` suspends while `loading` and throws to the nearest error boundary for `error` and `crashed`. Sync problems after the first data never reach the boundary; read -`errors` from the hook. +`health` from the hook. ## For adapter authors @@ -317,7 +317,5 @@ sync: ({ | node `phase`, `final`, `isError` | node `failureReason` or `error` | | `cleanup()` then `preload()` to recover | `reload()` | | `cleanup()` with live queries mounted | throws; unmount first or `cleanup({ dependents: true })` | -| `persistedStatus`, `isPersistedReady`, `persistedError` | `status` and `errors` | -| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `errors`, `reload()` | -| `collection.health`, `health:change` | `collection.errors`, `errors:change` | -| Effect `onHealthChange` | `onErrorsChange` | +| `persistedStatus`, `isPersistedReady`, `persistedError` | `status` and `health` | +| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `health`, `reload()` | diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 1340c3de3c..8558f59cc9 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -59,23 +59,23 @@ production queues, caches, or semantic helpers merely to share their names. `source session`. Use `sync run` for the local Collection sync invocation and a provider-qualified `provider session` for a remote lifecycle. -## Collection status and error terms +## Collection status and health terms -Collection status and the error tree are separate axes. Status says whether the -data can be shown; the error tree says whether the data on screen is still kept +Collection status and the health tree are separate axes. Status says whether the +data can be shown; the health tree says whether the data on screen is still kept current. After Collection readiness in a sync run, only a crash changes status until cleanup ends the run. | Term | Meaning | Do not use as a synonym | | --- | --- | --- | -| initial gate | The role of Collection status during a sync run's first load. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `fail()`. After Collection readiness in a sync run, sync problems never change the status. | The error tree or a live query's request errors. | +| initial gate | The role of Collection status during a sync run's first load. The status stays `loading` while the sync adapter retries, then becomes `ready` through `markReady()` or `error` through `fail()`. After Collection readiness in a sync run, sync problems never change the status. | The health tree or a live query's request errors. | | loading error | The `fail()` that ends a sync run's first load in status `error` (`isLoadingError`). It is final for its sync run: `markReady()` afterward throws, and only a new sync run can reach `ready`. | Sync error or a retry. | | crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It cannot be recovered in place: nothing restarts it and `reload()` rejects. Only cleanup leaves it, and cleanup discards all of the Collection's state, so a later start is a fresh start. After a crash, the sync adapter's status calls (`markReady()`, `fail()`, `reportError()`, `reportHealthy()`) do nothing. Sync data that the run commits still applies, because closure keeps accepted sync obligations such as the confirmation of a write admitted before it. | Loading error, sync error, or cleanup. | -| sync error | A problem after Collection readiness on a source's error tree node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | +| sync error | A problem after Collection readiness on a source's health node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | | request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. A source's reload whose replay cannot load the query's data again is also a request error, until a later replay succeeds. | Sync error or request data. | -| error tree | The `errors` value of a Collection, live query, or Effect: one node for a Collection; for a query, one error tree node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `errors:change` announces a change. | An error list. | -| error tree node | One `ErrorTreeNode` in an error tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. A nested live-query node carries its own error tree in `sources`. | Bucket or Collection facade. | +| health tree | The `health` value of a Collection, live query, or Effect: one node for a Collection; for a query, one health node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `health:change` announces a change. | An error list. | +| health node | One `HealthNode` in a health tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. A nested live-query node carries its own health tree in `sources`. | Bucket or Collection facade. | ## Persistence coordination terms diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 1b673ce9d6..e4ff30c6b2 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and error tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected error tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`errors:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | @@ -634,7 +634,7 @@ generic visitor behavior outside the named collectors. | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the error tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | +| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the health tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | | Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | @@ -3666,7 +3666,7 @@ Remaining witnesses before broader closure: - Electric tag/history owners: unknown or missing key-set evidence combined with tagged scoped demand, multiple resets overlapping held application, and partial updates arriving before a scoped baseline. Repeated provider - errors, SDK retry-loop exhaustion, and a + health, SDK retry-loop exhaustion, and a scoped reset overlapping a held applied receipt also need witnesses. The untagged SQLite metadata-read race is covered above. - Persistence/coordinator owners: native SQLite reads, browser/electron diff --git a/docs/framework/angular/reference/functions/injectLiveQuery.md b/docs/framework/angular/reference/functions/injectLiveQuery.md index 0bb18bb03e..e384b2c209 100644 --- a/docs/framework/angular/reference/functions/injectLiveQuery.md +++ b/docs/framework/angular/reference/functions/injectLiveQuery.md @@ -9,7 +9,7 @@ title: injectLiveQuery function injectLiveQuery<TContext, TParams>(options): InjectLiveQueryResult<TContext>; ``` -Defined in: [index.ts:119](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L119) +Defined in: [index.ts:110](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L110) ### Type Parameters @@ -43,7 +43,7 @@ Defined in: [index.ts:119](https://github.com/TanStack/db/blob/main/packages/ang function injectLiveQuery<TContext, TParams>(options): InjectConditionalLiveQueryResult<TContext>; ``` -Defined in: [index.ts:129](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L129) +Defined in: [index.ts:120](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L120) ### Type Parameters @@ -77,7 +77,7 @@ Defined in: [index.ts:129](https://github.com/TanStack/db/blob/main/packages/ang function injectLiveQuery<TContext>(queryFn): InjectLiveQueryResult<TContext>; ``` -Defined in: [index.ts:139](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L139) +Defined in: [index.ts:130](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L130) ### Type Parameters @@ -101,7 +101,7 @@ Defined in: [index.ts:139](https://github.com/TanStack/db/blob/main/packages/ang function injectLiveQuery<TContext>(queryFn): InjectConditionalLiveQueryResult<TContext>; ``` -Defined in: [index.ts:142](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L142) +Defined in: [index.ts:133](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L133) ### Type Parameters @@ -125,7 +125,7 @@ Defined in: [index.ts:142](https://github.com/TanStack/db/blob/main/packages/ang function injectLiveQuery<TContext>(config): InjectLiveQueryResult<TContext>; ``` -Defined in: [index.ts:147](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L147) +Defined in: [index.ts:138](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L138) ### Type Parameters @@ -149,7 +149,7 @@ Defined in: [index.ts:147](https://github.com/TanStack/db/blob/main/packages/ang function injectLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): InjectLiveQueryResultWithCollection<TResult, TKey, TUtils>; ``` -Defined in: [index.ts:151](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L151) +Defined in: [index.ts:142](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L142) ### Type Parameters @@ -181,7 +181,7 @@ Defined in: [index.ts:151](https://github.com/TanStack/db/blob/main/packages/ang function injectLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): InjectLiveQueryResultWithSingleResultCollection<TResult, TKey, TUtils>; ``` -Defined in: [index.ts:159](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L159) +Defined in: [index.ts:150](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L150) ### Type Parameters diff --git a/docs/framework/angular/reference/index.md b/docs/framework/angular/reference/index.md index 002bc2da12..33f0f1ce56 100644 --- a/docs/framework/angular/reference/index.md +++ b/docs/framework/angular/reference/index.md @@ -5,6 +5,7 @@ title: "@tanstack/angular-db" ## Interfaces +- [InjectLiveQueryFlags](interfaces/InjectLiveQueryFlags.md) - [InjectLiveQueryResult](interfaces/InjectLiveQueryResult.md) - [InjectLiveQueryResultWithCollection](interfaces/InjectLiveQueryResultWithCollection.md) - [InjectLiveQueryResultWithSingleResultCollection](interfaces/InjectLiveQueryResultWithSingleResultCollection.md) diff --git a/docs/framework/angular/reference/interfaces/InjectLiveQueryFlags.md b/docs/framework/angular/reference/interfaces/InjectLiveQueryFlags.md new file mode 100644 index 0000000000..f31f7ccd93 --- /dev/null +++ b/docs/framework/angular/reference/interfaces/InjectLiveQueryFlags.md @@ -0,0 +1,122 @@ +--- +id: InjectLiveQueryFlags +title: InjectLiveQueryFlags +--- + +Defined in: [index.ts:42](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L42) + +Status, persistence, and health signals every `injectLiveQuery` result has. + +## Extended by + +- [`InjectLiveQueryResult`](InjectLiveQueryResult.md) +- [`InjectLiveQueryResultWithCollection`](InjectLiveQueryResultWithCollection.md) +- [`InjectLiveQueryResultWithSingleResultCollection`](InjectLiveQueryResultWithSingleResultCollection.md) + +## Properties + +### health + +```ts +health: Signal<HealthNode | undefined>; +``` + +Defined in: [index.ts:60](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L60) + +A signal containing the health tree for the query and its dependencies + +*** + +### isCleanedUp + +```ts +isCleanedUp: Signal<boolean>; +``` + +Defined in: [index.ts:56](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L56) + +A signal indicating whether the collection has been cleaned up + +*** + +### isCrashed + +```ts +isCrashed: Signal<boolean>; +``` + +Defined in: [index.ts:54](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L54) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +*** + +### isIdle + +```ts +isIdle: Signal<boolean>; +``` + +Defined in: [index.ts:50](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L50) + +A signal indicating whether the collection is idle + +*** + +### isLoading + +```ts +isLoading: Signal<boolean>; +``` + +Defined in: [index.ts:46](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L46) + +A signal indicating whether the collection is currently loading + +*** + +### isLoadingError + +```ts +isLoadingError: Signal<boolean>; +``` + +Defined in: [index.ts:52](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L52) + +The first load failed: there is nothing to show. + +*** + +### isReady + +```ts +isReady: Signal<boolean>; +``` + +Defined in: [index.ts:48](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L48) + +A signal indicating whether the collection is ready + +*** + +### isSyncError + +```ts +isSyncError: Signal<boolean>; +``` + +Defined in: [index.ts:58](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L58) + +The data is shown, and a sync problem or failed request is in `health`. + +*** + +### status + +```ts +status: Signal<CollectionStatus | "disabled">; +``` + +Defined in: [index.ts:44](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L44) + +A signal containing the current status of the collection diff --git a/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md b/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md index a9ffbe32b2..baa846ecee 100644 --- a/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md +++ b/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md @@ -3,10 +3,13 @@ id: InjectLiveQueryResult title: InjectLiveQueryResult --- -Defined in: [index.ts:41](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L41) +Defined in: [index.ts:63](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L63) -The result of calling `injectLiveQuery`. -Contains reactive signals for the query state and data. +Status, persistence, and health signals every `injectLiveQuery` result has. + +## Extends + +- [`InjectLiveQueryFlags`](InjectLiveQueryFlags.md) ## Type Parameters @@ -25,7 +28,7 @@ collection: Signal< | null>; ``` -Defined in: [index.ts:47](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L47) +Defined in: [index.ts:71](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L71) A signal containing the underlying collection instance (null for disabled queries) @@ -37,33 +40,57 @@ A signal containing the underlying collection instance (null for disabled querie data: Signal<InferResultType<TContext>>; ``` -Defined in: [index.ts:45](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L45) +Defined in: [index.ts:69](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L69) A signal containing the results as an array, or single result for findOne queries *** +### health + +```ts +health: Signal<HealthNode | undefined>; +``` + +Defined in: [index.ts:60](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L60) + +A signal containing the health tree for the query and its dependencies + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`health`](InjectLiveQueryFlags.md#health) + +*** + ### isCleanedUp ```ts isCleanedUp: Signal<boolean>; ``` -Defined in: [index.ts:66](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L66) +Defined in: [index.ts:56](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L56) A signal indicating whether the collection has been cleaned up +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isCleanedUp`](InjectLiveQueryFlags.md#iscleanedup) + *** -### isError +### isCrashed ```ts -isError: Signal<boolean>; +isCrashed: Signal<boolean>; ``` -Defined in: [index.ts:64](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L64) +Defined in: [index.ts:54](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L54) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. -A signal indicating whether the collection has an error +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isCrashed`](InjectLiveQueryFlags.md#iscrashed) *** @@ -73,10 +100,14 @@ A signal indicating whether the collection has an error isIdle: Signal<boolean>; ``` -Defined in: [index.ts:62](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L62) +Defined in: [index.ts:50](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L50) A signal indicating whether the collection is idle +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isIdle`](InjectLiveQueryFlags.md#isidle) + *** ### isLoading @@ -85,19 +116,29 @@ A signal indicating whether the collection is idle isLoading: Signal<boolean>; ``` -Defined in: [index.ts:55](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L55) +Defined in: [index.ts:46](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L46) A signal indicating whether the collection is currently loading +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isLoading`](InjectLiveQueryFlags.md#isloading) + *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: Signal<boolean>; +isLoadingError: Signal<boolean>; ``` -Defined in: [index.ts:59](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L59) +Defined in: [index.ts:52](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L52) + +The first load failed: there is nothing to show. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isLoadingError`](InjectLiveQueryFlags.md#isloadingerror) *** @@ -107,30 +148,30 @@ Defined in: [index.ts:59](https://github.com/TanStack/db/blob/main/packages/angu isReady: Signal<boolean>; ``` -Defined in: [index.ts:57](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L57) +Defined in: [index.ts:48](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L48) A signal indicating whether the collection is ready -*** +#### Inherited from -### persistedError - -```ts -persistedError: Signal<unknown>; -``` - -Defined in: [index.ts:60](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L60) +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isReady`](InjectLiveQueryFlags.md#isready) *** -### persistedStatus +### isSyncError ```ts -persistedStatus: Signal<LiveQueryPersistedStatus>; +isSyncError: Signal<boolean>; ``` Defined in: [index.ts:58](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L58) +The data is shown, and a sync problem or failed request is in `health`. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isSyncError`](InjectLiveQueryFlags.md#issyncerror) + *** ### state @@ -139,7 +180,7 @@ Defined in: [index.ts:58](https://github.com/TanStack/db/blob/main/packages/angu state: Signal<Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }>>; ``` -Defined in: [index.ts:43](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L43) +Defined in: [index.ts:67](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L67) A signal containing the complete state map of results keyed by their ID @@ -151,6 +192,10 @@ A signal containing the complete state map of results keyed by their ID status: Signal<CollectionStatus | "disabled">; ``` -Defined in: [index.ts:53](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L53) +Defined in: [index.ts:44](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L44) A signal containing the current status of the collection + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`status`](InjectLiveQueryFlags.md#status) diff --git a/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithCollection.md b/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithCollection.md index 5892128e83..fa2a9b4393 100644 --- a/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithCollection.md +++ b/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithCollection.md @@ -3,7 +3,13 @@ id: InjectLiveQueryResultWithCollection title: InjectLiveQueryResultWithCollection --- -Defined in: [index.ts:81](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L81) +Defined in: [index.ts:90](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L90) + +Status, persistence, and health signals every `injectLiveQuery` result has. + +## Extends + +- [`InjectLiveQueryFlags`](InjectLiveQueryFlags.md) ## Type Parameters @@ -30,7 +36,7 @@ collection: Signal< | null>; ``` -Defined in: [index.ts:88](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L88) +Defined in: [index.ts:97](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L97) *** @@ -40,7 +46,23 @@ Defined in: [index.ts:88](https://github.com/TanStack/db/blob/main/packages/angu data: Signal<TResult[]>; ``` -Defined in: [index.ts:87](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L87) +Defined in: [index.ts:96](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L96) + +*** + +### health + +```ts +health: Signal<HealthNode | undefined>; +``` + +Defined in: [index.ts:60](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L60) + +A signal containing the health tree for the query and its dependencies + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`health`](InjectLiveQueryFlags.md#health) *** @@ -50,17 +72,29 @@ Defined in: [index.ts:87](https://github.com/TanStack/db/blob/main/packages/angu isCleanedUp: Signal<boolean>; ``` -Defined in: [index.ts:97](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L97) +Defined in: [index.ts:56](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L56) + +A signal indicating whether the collection has been cleaned up + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isCleanedUp`](InjectLiveQueryFlags.md#iscleanedup) *** -### isError +### isCrashed ```ts -isError: Signal<boolean>; +isCrashed: Signal<boolean>; ``` -Defined in: [index.ts:96](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L96) +Defined in: [index.ts:54](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L54) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isCrashed`](InjectLiveQueryFlags.md#iscrashed) *** @@ -70,7 +104,13 @@ Defined in: [index.ts:96](https://github.com/TanStack/db/blob/main/packages/angu isIdle: Signal<boolean>; ``` -Defined in: [index.ts:95](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L95) +Defined in: [index.ts:50](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L50) + +A signal indicating whether the collection is idle + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isIdle`](InjectLiveQueryFlags.md#isidle) *** @@ -80,17 +120,29 @@ Defined in: [index.ts:95](https://github.com/TanStack/db/blob/main/packages/angu isLoading: Signal<boolean>; ``` -Defined in: [index.ts:90](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L90) +Defined in: [index.ts:46](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L46) + +A signal indicating whether the collection is currently loading + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isLoading`](InjectLiveQueryFlags.md#isloading) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: Signal<boolean>; +isLoadingError: Signal<boolean>; ``` -Defined in: [index.ts:93](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L93) +Defined in: [index.ts:52](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L52) + +The first load failed: there is nothing to show. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isLoadingError`](InjectLiveQueryFlags.md#isloadingerror) *** @@ -100,27 +152,29 @@ Defined in: [index.ts:93](https://github.com/TanStack/db/blob/main/packages/angu isReady: Signal<boolean>; ``` -Defined in: [index.ts:91](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L91) +Defined in: [index.ts:48](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L48) -*** - -### persistedError +A signal indicating whether the collection is ready -```ts -persistedError: Signal<unknown>; -``` +#### Inherited from -Defined in: [index.ts:94](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L94) +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isReady`](InjectLiveQueryFlags.md#isready) *** -### persistedStatus +### isSyncError ```ts -persistedStatus: Signal<LiveQueryPersistedStatus>; +isSyncError: Signal<boolean>; ``` -Defined in: [index.ts:92](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L92) +Defined in: [index.ts:58](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L58) + +The data is shown, and a sync problem or failed request is in `health`. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isSyncError`](InjectLiveQueryFlags.md#issyncerror) *** @@ -130,7 +184,7 @@ Defined in: [index.ts:92](https://github.com/TanStack/db/blob/main/packages/angu state: Signal<Map<TKey, TResult>>; ``` -Defined in: [index.ts:86](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L86) +Defined in: [index.ts:95](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L95) *** @@ -140,4 +194,10 @@ Defined in: [index.ts:86](https://github.com/TanStack/db/blob/main/packages/angu status: Signal<CollectionStatus | "disabled">; ``` -Defined in: [index.ts:89](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L89) +Defined in: [index.ts:44](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L44) + +A signal containing the current status of the collection + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`status`](InjectLiveQueryFlags.md#status) diff --git a/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithSingleResultCollection.md b/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithSingleResultCollection.md index e456bd589b..54b30a750f 100644 --- a/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithSingleResultCollection.md +++ b/docs/framework/angular/reference/interfaces/InjectLiveQueryResultWithSingleResultCollection.md @@ -5,6 +5,12 @@ title: InjectLiveQueryResultWithSingleResultCollection Defined in: [index.ts:100](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L100) +Status, persistence, and health signals every `injectLiveQuery` result has. + +## Extends + +- [`InjectLiveQueryFlags`](InjectLiveQueryFlags.md) + ## Type Parameters ### TResult @@ -44,23 +50,51 @@ Defined in: [index.ts:106](https://github.com/TanStack/db/blob/main/packages/ang *** +### health + +```ts +health: Signal<HealthNode | undefined>; +``` + +Defined in: [index.ts:60](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L60) + +A signal containing the health tree for the query and its dependencies + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`health`](InjectLiveQueryFlags.md#health) + +*** + ### isCleanedUp ```ts isCleanedUp: Signal<boolean>; ``` -Defined in: [index.ts:116](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L116) +Defined in: [index.ts:56](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L56) + +A signal indicating whether the collection has been cleaned up + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isCleanedUp`](InjectLiveQueryFlags.md#iscleanedup) *** -### isError +### isCrashed ```ts -isError: Signal<boolean>; +isCrashed: Signal<boolean>; ``` -Defined in: [index.ts:115](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L115) +Defined in: [index.ts:54](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L54) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isCrashed`](InjectLiveQueryFlags.md#iscrashed) *** @@ -70,7 +104,13 @@ Defined in: [index.ts:115](https://github.com/TanStack/db/blob/main/packages/ang isIdle: Signal<boolean>; ``` -Defined in: [index.ts:114](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L114) +Defined in: [index.ts:50](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L50) + +A signal indicating whether the collection is idle + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isIdle`](InjectLiveQueryFlags.md#isidle) *** @@ -80,17 +120,29 @@ Defined in: [index.ts:114](https://github.com/TanStack/db/blob/main/packages/ang isLoading: Signal<boolean>; ``` -Defined in: [index.ts:109](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L109) +Defined in: [index.ts:46](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L46) + +A signal indicating whether the collection is currently loading + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isLoading`](InjectLiveQueryFlags.md#isloading) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: Signal<boolean>; +isLoadingError: Signal<boolean>; ``` -Defined in: [index.ts:112](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L112) +Defined in: [index.ts:52](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L52) + +The first load failed: there is nothing to show. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isLoadingError`](InjectLiveQueryFlags.md#isloadingerror) *** @@ -100,27 +152,29 @@ Defined in: [index.ts:112](https://github.com/TanStack/db/blob/main/packages/ang isReady: Signal<boolean>; ``` -Defined in: [index.ts:110](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L110) +Defined in: [index.ts:48](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L48) -*** +A signal indicating whether the collection is ready -### persistedError +#### Inherited from -```ts -persistedError: Signal<unknown>; -``` - -Defined in: [index.ts:113](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L113) +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isReady`](InjectLiveQueryFlags.md#isready) *** -### persistedStatus +### isSyncError ```ts -persistedStatus: Signal<LiveQueryPersistedStatus>; +isSyncError: Signal<boolean>; ``` -Defined in: [index.ts:111](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L111) +Defined in: [index.ts:58](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L58) + +The data is shown, and a sync problem or failed request is in `health`. + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`isSyncError`](InjectLiveQueryFlags.md#issyncerror) *** @@ -140,4 +194,10 @@ Defined in: [index.ts:105](https://github.com/TanStack/db/blob/main/packages/ang status: Signal<CollectionStatus | "disabled">; ``` -Defined in: [index.ts:108](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L108) +Defined in: [index.ts:44](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L44) + +A signal containing the current status of the collection + +#### Inherited from + +[`InjectLiveQueryFlags`](InjectLiveQueryFlags.md).[`status`](InjectLiveQueryFlags.md#status) diff --git a/docs/framework/angular/reference/type-aliases/InjectConditionalLiveQueryResult.md b/docs/framework/angular/reference/type-aliases/InjectConditionalLiveQueryResult.md index 9f231ce34a..50520ab176 100644 --- a/docs/framework/angular/reference/type-aliases/InjectConditionalLiveQueryResult.md +++ b/docs/framework/angular/reference/type-aliases/InjectConditionalLiveQueryResult.md @@ -7,7 +7,7 @@ title: InjectConditionalLiveQueryResult type InjectConditionalLiveQueryResult<TContext> = Omit<InjectLiveQueryResult<TContext>, "data"> & object; ``` -Defined in: [index.ts:74](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L74) +Defined in: [index.ts:83](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L83) ## Type Declaration diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 12d150e98e..927880fb35 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -180,7 +180,7 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( ) ``` -`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in the returned `errors` tree. +`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in the returned `health` tree. This hook widens an ordered live query; it is not TanStack Query's `useInfiniteQuery`. `getNextPageParam` was previously ignored and is now rejected. diff --git a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md index 29a55ad3f8..3d171e4ae6 100644 --- a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery<TResult, TKey, TUtils>(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>; ``` -Defined in: [useLiveInfiniteQuery.ts:166](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L166) +Defined in: [useLiveInfiniteQuery.ts:167](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L167) Create an infinite query using a query function with live updates. @@ -57,7 +57,7 @@ function useLiveInfiniteQuery<TContext>( deps?): UseLiveInfiniteQueryReturn<TContext>; ``` -Defined in: [useLiveInfiniteQuery.ts:176](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L176) +Defined in: [useLiveInfiniteQuery.ts:177](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L177) Create an infinite query using a query function with live updates. diff --git a/docs/framework/react/reference/functions/useLiveQuery.md b/docs/framework/react/reference/functions/useLiveQuery.md index 7e8833b9be..91825a7421 100644 --- a/docs/framework/react/reference/functions/useLiveQuery.md +++ b/docs/framework/react/reference/functions/useLiveQuery.md @@ -6,10 +6,10 @@ title: useLiveQuery ## Call Signature ```ts -function useLiveQuery<TContext>(queryFn, deps?): object; +function useLiveQuery<TContext>(queryFn, deps?): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:387](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L387) +Defined in: [useLiveQuery.ts:403](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L403) Create a live query using a query function. @@ -35,89 +35,10 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}>; -``` - -#### data - -```ts -data: InferResultType<TContext>; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: true; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }>; -``` - -#### status - -```ts -status: CollectionStatus; -``` - ### Examples ```ts @@ -176,12 +97,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -193,10 +114,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(queryFn, deps?): object; +function useLiveQuery<TContext>(queryFn, deps?): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:407](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L407) +Defined in: [useLiveQuery.ts:415](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L415) Create a live query using a query function. @@ -222,93 +143,10 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: - | Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}, StandardSchemaV1<unknown, unknown>, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType<TContext> | undefined; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: boolean; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: - | Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### status - -```ts -status: UseLiveQueryStatus; -``` - ### Examples ```ts @@ -367,12 +205,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -384,7 +222,7 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(queryFn, deps?): object; +function useLiveQuery<TContext>(queryFn, deps?): object & UseLiveQueryFlags; ``` Defined in: [useLiveQuery.ts:429](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L429) @@ -416,93 +254,10 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: - | Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}, StandardSchemaV1<unknown, unknown>, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType<TContext> | undefined; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: boolean; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: - | Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### status - -```ts -status: UseLiveQueryStatus; -``` - ### Examples ```ts @@ -561,12 +316,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -578,10 +333,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TResult, TKey, TUtils>(queryFn, deps?): object; +function useLiveQuery<TResult, TKey, TUtils>(queryFn, deps?): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:451](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L451) +Defined in: [useLiveQuery.ts:443](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L443) Create a live query using a query function. @@ -618,90 +373,10 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: - | Collection<TResult, TKey, TUtils, StandardSchemaV1<unknown, unknown>, TResult> - | undefined; -``` - -#### data - -```ts -data: TResult[] | undefined; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: boolean; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: Map<TKey, TResult> | undefined; -``` - -#### status - -```ts -status: UseLiveQueryStatus; -``` - ### Examples ```ts @@ -760,12 +435,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -777,10 +452,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext, TResult, TKey, TUtils>(queryFn, deps?): object; +function useLiveQuery<TContext, TResult, TKey, TUtils>(queryFn, deps?): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:477](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L477) +Defined in: [useLiveQuery.ts:461](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L461) Create a live query using a query function. @@ -823,159 +498,74 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection +### Examples ```ts -collection: - | Collection<TResult, TKey, TUtils, StandardSchemaV1<unknown, unknown>, TResult> - | Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}, StandardSchemaV1<unknown, unknown>, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; +// Prefer config object syntax +const { data, isLoading } = useLiveQuery({ + query: (q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +}) ``` -#### data - ```ts -data: InferResultType<TContext> | TResult[] | undefined; +// Single result query +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +}) ``` -#### isCleanedUp - ```ts -isCleanedUp: boolean; +// Structured captured values are included in derived query identity +const { data, state } = useLiveQuery({ + query: (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), +}) ``` -#### isEnabled - ```ts -isEnabled: boolean; +// Return undefined or null to disable a query +const { data, isEnabled } = useLiveQuery({ + query: (q) => { + if (!userId) return undefined + return q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + }, +}) ``` -#### isError - ```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: - | Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | Map<TKey, TResult> - | undefined; -``` - -#### status - -```ts -status: UseLiveQueryStatus; -``` - -### Examples - -```ts -// Prefer config object syntax -const { data, isLoading } = useLiveQuery({ - query: (q) => - q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.completed, false)) - .select(({ todos }) => ({ id: todos.id, text: todos.text })) -}) -``` - -```ts -// Single result query -const { data } = useLiveQuery({ - query: (q) => q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.id, 1)) - .findOne() -}) -``` - -```ts -// Structured captured values are included in derived query identity -const { data, state } = useLiveQuery({ - query: (q) => q.from({ todos: todosCollection }) - .where(({ todos }) => gt(todos.priority, minPriority)), -}) -``` - -```ts -// Return undefined or null to disable a query -const { data, isEnabled } = useLiveQuery({ - query: (q) => { - if (!userId) return undefined - return q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.userId, userId)) - }, -}) -``` - -```ts -// Join pattern -const { data } = useLiveQuery({ - query: (q) => - q.from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - userName: persons.name - })) -}) +// Join pattern +const { data } = useLiveQuery({ + query: (q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +}) ``` ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -987,10 +577,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(config): object; +function useLiveQuery<TContext>(config): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:548](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L548) +Defined in: [useLiveQuery.ts:524](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L524) Create a live query using configuration object @@ -1010,89 +600,10 @@ Configuration object with query and options ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}>; -``` - -#### data - -```ts -data: InferResultType<TContext>; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: true; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }>; -``` - -#### status - -```ts -status: CollectionStatus; -``` - ### Examples ```ts @@ -1117,12 +628,12 @@ const { data, isReady } = useLiveQuery({ ```ts // Handle all states uniformly -const { data, isLoading, isReady, isError } = useLiveQuery({ +const { data, isLoading, isReady, isLoadingError } = useLiveQuery({ query: (q) => q.from({ items: itemCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Something went wrong</div> +if (isLoadingError) return <div>Something went wrong</div> if (!isReady) return <div>Preparing...</div> return <div>{data.length} items loaded</div> @@ -1131,10 +642,10 @@ return <div>{data.length} items loaded</div> ## Call Signature ```ts -function useLiveQuery<TContext>(config): object; +function useLiveQuery<TContext>(config): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:567](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L567) +Defined in: [useLiveQuery.ts:535](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L535) Create a live query using a query function. @@ -1152,93 +663,10 @@ Create a live query using a query function. ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: - | Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}, StandardSchemaV1<unknown, unknown>, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType<TContext> | undefined; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: boolean; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: - | Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### status - -```ts -status: UseLiveQueryStatus; -``` - ### Examples ```ts @@ -1297,12 +725,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -1314,10 +742,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(config, deps?): object; +function useLiveQuery<TContext>(config, deps?): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:586](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L586) +Defined in: [useLiveQuery.ts:546](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L546) Create a live query using a query function. @@ -1341,89 +769,10 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}>; -``` - -#### data - -```ts -data: InferResultType<TContext>; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: true; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }>; -``` - -#### status - -```ts -status: CollectionStatus; -``` - ### Examples ```ts @@ -1482,12 +831,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -1499,10 +848,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(config, deps): object; +function useLiveQuery<TContext>(config, deps): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:606](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L606) +Defined in: [useLiveQuery.ts:558](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L558) Create a live query using a query function. @@ -1526,93 +875,10 @@ Deprecated array of dependencies that trigger query re-execution when changed ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: - | Collection<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, string | number, { -}, StandardSchemaV1<unknown, unknown>, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType<TContext> | undefined; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: boolean; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: - | Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }> - | undefined; -``` - -#### status - -```ts -status: UseLiveQueryStatus; -``` - ### Examples ```ts @@ -1671,12 +937,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> @@ -1688,10 +954,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): object; +function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:655](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L655) +Defined in: [useLiveQuery.ts:599](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L599) Subscribe to an existing live query collection @@ -1719,88 +985,10 @@ Pre-created live query collection to subscribe to ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: Collection<TResult, TKey, TUtils>; -``` - -#### data - -```ts -data: TResult[]; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: true; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: Map<TKey, TResult>; -``` - -#### status - -```ts -status: CollectionStatus; -``` - ### Examples ```ts @@ -1823,10 +1011,10 @@ const handleToggle = (id) => { ```ts // Handle states consistently -const { data, isLoading, isError } = useLiveQuery(sharedCollection) +const { data, isLoading, isLoadingError } = useLiveQuery(sharedCollection) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error loading data</div> +if (isLoadingError) return <div>Error loading data</div> return <div>{data.map(item => <Item key={item.id} {...item} />)}</div> ``` @@ -1834,10 +1022,10 @@ return <div>{data.map(item => <Item key={item.id} {...item} />)}</div> ## Call Signature ```ts -function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): object; +function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:678](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L678) +Defined in: [useLiveQuery.ts:614](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L614) Create a live query using a query function. @@ -1863,88 +1051,10 @@ Create a live query using a query function. ### Returns -`object` +`object` & `UseLiveQueryFlags` Object with reactive data, state, and status information -#### collection - -```ts -collection: Collection<TResult, TKey, TUtils, StandardSchemaV1<unknown, unknown>, TResult> & SingleResult; -``` - -#### data - -```ts -data: TResult | undefined; -``` - -#### isCleanedUp - -```ts -isCleanedUp: boolean; -``` - -#### isEnabled - -```ts -isEnabled: true; -``` - -#### isError - -```ts -isError: boolean; -``` - -#### isIdle - -```ts -isIdle: boolean; -``` - -#### isLoading - -```ts -isLoading: boolean; -``` - -#### isPersistedReady - -```ts -isPersistedReady: boolean; -``` - -#### isReady - -```ts -isReady: boolean; -``` - -#### persistedError - -```ts -persistedError: unknown; -``` - -#### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -#### state - -```ts -state: Map<TKey, TResult>; -``` - -#### status - -```ts -status: CollectionStatus; -``` - ### Examples ```ts @@ -2003,12 +1113,12 @@ const { data } = useLiveQuery({ ```ts // Handle loading and error states -const { data, isLoading, isError, status } = useLiveQuery({ +const { data, isLoading, isLoadingError, status } = useLiveQuery({ query: (q) => q.from({ todos: todoCollection }) }) if (isLoading) return <div>Loading...</div> -if (isError) return <div>Error: {status}</div> +if (isLoadingError) return <div>Error: {status}</div> return ( <ul> diff --git a/docs/framework/react/reference/functions/useLiveSuspenseQuery.md b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md index c07e520d8e..5eba80e0b0 100644 --- a/docs/framework/react/reference/functions/useLiveSuspenseQuery.md +++ b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md @@ -9,7 +9,7 @@ title: useLiveSuspenseQuery function useLiveSuspenseQuery<TContext>(queryFn, deps?): object; ``` -Defined in: [useLiveSuspenseQuery.ts:151](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L151) +Defined in: [useLiveSuspenseQuery.ts:152](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L152) Create a live query with React Suspense support @@ -52,6 +52,12 @@ collection: Collection<{ [K in string | number | symbol]: ResultValue<TContext>[ data: InferResultType<TContext>; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -162,7 +168,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery<TContext>(config): object; ``` -Defined in: [useLiveSuspenseQuery.ts:161](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L161) +Defined in: [useLiveSuspenseQuery.ts:163](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L163) Create a live query with React Suspense support @@ -197,6 +203,12 @@ collection: Collection<{ [K in string | number | symbol]: ResultValue<TContext>[ data: InferResultType<TContext>; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -307,7 +319,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery<TContext>(config, deps?): object; ``` -Defined in: [useLiveSuspenseQuery.ts:170](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L170) +Defined in: [useLiveSuspenseQuery.ts:173](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L173) Create a live query with React Suspense support @@ -348,6 +360,12 @@ collection: Collection<{ [K in string | number | symbol]: ResultValue<TContext>[ data: InferResultType<TContext>; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -458,7 +476,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery<TResult, TKey, TUtils>(liveQueryCollection): object; ``` -Defined in: [useLiveSuspenseQuery.ts:180](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L180) +Defined in: [useLiveSuspenseQuery.ts:184](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L184) Create a live query with React Suspense support @@ -500,6 +518,12 @@ collection: Collection<TResult, TKey, TUtils>; data: TResult[]; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -610,7 +634,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery<TResult, TKey, TUtils>(liveQueryCollection): object; ``` -Defined in: [useLiveSuspenseQuery.ts:193](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L193) +Defined in: [useLiveSuspenseQuery.ts:198](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L198) Create a live query with React Suspense support @@ -652,6 +676,12 @@ collection: Collection<TResult, TKey, TUtils, StandardSchemaV1<unknown, unknown> data: TResult | undefined; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md index a4501cd291..ac1d9824d1 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -17,12 +17,6 @@ Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/mai data: InferResultType<TContext>; ``` -### error - -```ts -error: unknown; -``` - ### fetchNextPage() ```ts diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index ffe9403754..01e32de118 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturnWithCollection type UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils> = object; ``` -Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L81) +Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L80) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/mai collection: Collection<TResult, TKey, TUtils> & NonSingleResult; ``` -Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) +Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) *** @@ -41,17 +41,7 @@ Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/mai data: TResult[]; ``` -Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) - -*** - -### error - -```ts -error: unknown; -``` - -Defined in: [useLiveInfiniteQuery.ts:104](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L104) +Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L85) *** @@ -61,7 +51,7 @@ Defined in: [useLiveInfiniteQuery.ts:104](https://github.com/TanStack/db/blob/ma fetchNextPage: () => Promise<void>; ``` -Defined in: [useLiveInfiniteQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L101) +Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L102) #### Returns @@ -75,7 +65,17 @@ Defined in: [useLiveInfiniteQuery.ts:101](https://github.com/TanStack/db/blob/ma hasNextPage: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L102) +Defined in: [useLiveInfiniteQuery.ts:103](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L103) + +*** + +### health + +```ts +health: HealthNode | undefined; +``` + +Defined in: [useLiveInfiniteQuery.ts:105](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L105) *** @@ -85,27 +85,29 @@ Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/ma isCleanedUp: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:97](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L97) +Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L96) *** -### isEnabled +### isCrashed ```ts -isEnabled: true; +isCrashed: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L98) +Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L95) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. *** -### isError +### isEnabled ```ts -isError: boolean; +isEnabled: true; ``` -Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L96) +Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L99) *** @@ -115,7 +117,7 @@ Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/mai isFetchingNextPage: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:103](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L103) +Defined in: [useLiveInfiniteQuery.ts:104](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L104) *** @@ -125,7 +127,7 @@ Defined in: [useLiveInfiniteQuery.ts:103](https://github.com/TanStack/db/blob/ma isIdle: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L95) +Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) *** @@ -135,18 +137,20 @@ Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/mai isLoading: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) +Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: boolean; +isLoadingError: boolean; ``` Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) +The first load failed: there is nothing to show. + *** ### isReady @@ -155,47 +159,39 @@ Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/mai isReady: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) +Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) *** -### pageParams +### isSyncError ```ts -pageParams: number[]; +isSyncError: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L100) - -*** - -### pages - -```ts -pages: TResult[][]; -``` +Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L98) -Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L99) +The data is shown, and a sync problem or failed page load is in `health`. *** -### persistedError +### pageParams ```ts -persistedError: unknown | undefined; +pageParams: number[]; ``` -Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L94) +Defined in: [useLiveInfiniteQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L101) *** -### persistedStatus +### pages ```ts -persistedStatus: LiveQueryPersistedStatus; +pages: TResult[][]; ``` -Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) +Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L100) *** @@ -205,7 +201,7 @@ Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/mai state: Map<TKey, TResult>; ``` -Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) +Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) *** @@ -215,4 +211,4 @@ Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/mai status: CollectionStatus; ``` -Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) +Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) diff --git a/docs/framework/solid/reference/functions/useLiveQuery.md b/docs/framework/solid/reference/functions/useLiveQuery.md index 065ee1bc2c..db19231d0e 100644 --- a/docs/framework/solid/reference/functions/useLiveQuery.md +++ b/docs/framework/solid/reference/functions/useLiveQuery.md @@ -6,10 +6,10 @@ title: useLiveQuery ## Call Signature ```ts -function useLiveQuery<TContext>(queryFn): Accessor<InferResultType<TContext>> & object; +function useLiveQuery<TContext>(queryFn): Accessor<InferResultType<TContext>> & object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L113) +Defined in: [useLiveQuery.ts:129](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L129) Create a live query using a query function @@ -76,7 +76,7 @@ return ( <Match when={todosQuery.isLoading}> <div>Loading...</div> </Match> - <Match when={todosQuery.isError}> + <Match when={todosQuery.isLoadingError}> <div>Error: {todosQuery.status}</div> </Match> <Match when={todosQuery.isReady}> @@ -106,10 +106,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(queryFn): Accessor<InferConditionalResultType<TContext>> & object; +function useLiveQuery<TContext>(queryFn): Accessor<InferConditionalResultType<TContext>> & object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:135](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L135) +Defined in: [useLiveQuery.ts:143](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L143) Create a live query using a query function @@ -176,7 +176,7 @@ return ( <Match when={todosQuery.isLoading}> <div>Loading...</div> </Match> - <Match when={todosQuery.isError}> + <Match when={todosQuery.isLoadingError}> <div>Error: {todosQuery.status}</div> </Match> <Match when={todosQuery.isReady}> @@ -206,7 +206,7 @@ return ( ## Call Signature ```ts -function useLiveQuery<TContext>(config): Accessor<InferResultType<TContext>> & object; +function useLiveQuery<TContext>(config): Accessor<InferResultType<TContext>> & object & UseLiveQueryFlags; ``` Defined in: [useLiveQuery.ts:199](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L199) @@ -262,7 +262,7 @@ return ( <Match when={itemsQuery.isLoading}> <div>Loading...</div> </Match> - <Match when={itemsQuery.isError}> + <Match when={itemsQuery.isLoadingError}> <div>Something went wrong</div> </Match> <Match when={!itemsQuery.isReady}> @@ -275,10 +275,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): Accessor<TResult[]> & object; +function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): Accessor<TResult[]> & object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:256](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L256) +Defined in: [useLiveQuery.ts:248](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L248) Subscribe to an existing live query collection @@ -337,7 +337,7 @@ return ( <Match when={sharedQuery.isLoading}> <div>Loading...</div> </Match> - <Match when={sharedQuery.isError}> + <Match when={sharedQuery.isLoadingError}> <div>Error loading data</div> </Match> </Switch> @@ -347,10 +347,10 @@ return ( ## Call Signature ```ts -function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): Accessor<TResult | undefined> & object; +function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): Accessor<TResult | undefined> & object & UseLiveQueryFlags; ``` -Defined in: [useLiveQuery.ts:284](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L284) +Defined in: [useLiveQuery.ts:268](https://github.com/TanStack/db/blob/main/packages/solid-db/src/useLiveQuery.ts#L268) Create a live query using a query function @@ -423,7 +423,7 @@ return ( <Match when={todosQuery.isLoading}> <div>Loading...</div> </Match> - <Match when={todosQuery.isError}> + <Match when={todosQuery.isLoadingError}> <div>Error: {todosQuery.status}</div> </Match> <Match when={todosQuery.isReady}> diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index ee6f6126d3..48732c5c1c 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -103,7 +103,7 @@ For ordered, paginated data with live updates, use `useLiveInfiniteQuery`: {/if} ``` -`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in `query.errors`. +`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in `query.health`. The query must include `orderBy`. The dependency array is available only with the query-function form. You can also pass an ordered, pre-created live query diff --git a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md index fa9dc3b9b9..c5bc982a02 100644 --- a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery<TResult, TKey, TUtils>(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:89](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L89) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:87](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L87) Create a Svelte-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. @@ -51,7 +51,7 @@ function useLiveInfiniteQuery<TContext>( deps?): UseLiveInfiniteQueryReturn<TContext>; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:100](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L100) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:98](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L98) Create a Svelte-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. diff --git a/docs/framework/svelte/reference/functions/useLiveQuery.md b/docs/framework/svelte/reference/functions/useLiveQuery.md index cd2ade1052..0d128cfa56 100644 --- a/docs/framework/svelte/reference/functions/useLiveQuery.md +++ b/docs/framework/svelte/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery<TContext>(queryFn, deps?): UseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, InferResultType<TContext>>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:210](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L210) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:218](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L218) Create a live query using a query function @@ -90,8 +90,8 @@ const todosQuery = useLiveQuery( const query = useLiveQuery((q) => q.from({ todos: todosCollection }) ) -const { data, isLoading, isError } = $derived(query) -// Now data, isLoading, and isError maintain reactivity +const { data, isLoading, isLoadingError } = $derived(query) +// Now data, isLoading, and isLoadingError maintain reactivity ``` ```ts @@ -118,7 +118,7 @@ const todosQuery = useLiveQuery((q) => // In template: // {#if todosQuery.isLoading} // <div>Loading...</div> -// {:else if todosQuery.isError} +// {:else if todosQuery.isLoadingError} // <div>Error: {todosQuery.status}</div> // {:else} // <ul> @@ -135,7 +135,7 @@ const todosQuery = useLiveQuery((q) => function useLiveQuery<TContext>(queryFn, deps?): ConditionalUseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, InferConditionalResultType<TContext>>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:216](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L216) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:224](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L224) Create a live query using a query function @@ -216,8 +216,8 @@ const todosQuery = useLiveQuery( const query = useLiveQuery((q) => q.from({ todos: todosCollection }) ) -const { data, isLoading, isError } = $derived(query) -// Now data, isLoading, and isError maintain reactivity +const { data, isLoading, isLoadingError } = $derived(query) +// Now data, isLoading, and isLoadingError maintain reactivity ``` ```ts @@ -244,7 +244,7 @@ const todosQuery = useLiveQuery((q) => // In template: // {#if todosQuery.isLoading} // <div>Loading...</div> -// {:else if todosQuery.isError} +// {:else if todosQuery.isLoadingError} // <div>Error: {todosQuery.status}</div> // {:else} // <ul> @@ -261,7 +261,7 @@ const todosQuery = useLiveQuery((q) => function useLiveQuery<TContext>(config, deps?): UseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue<TContext>[K] }, InferResultType<TContext>>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:264](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L264) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:272](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L272) Create a live query using configuration object @@ -319,7 +319,7 @@ const itemsQuery = useLiveQuery({ // In template: // {#if itemsQuery.isLoading} // <div>Loading...</div> -// {:else if itemsQuery.isError} +// {:else if itemsQuery.isLoadingError} // <div>Something went wrong</div> // {:else if !itemsQuery.isReady} // <div>Preparing...</div> @@ -334,7 +334,7 @@ const itemsQuery = useLiveQuery({ function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils, TResult[]>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:313](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L313) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:321](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L321) Subscribe to an existing query collection (can be reactive) @@ -402,7 +402,7 @@ const queryResult = useLiveQuery(sharedQuery) // In template: // {#if queryResult.isLoading} // <div>Loading...</div> -// {:else if queryResult.isError} +// {:else if queryResult.isLoadingError} // <div>Error loading data</div> // {:else} // {#each queryResult.data as item (item.id)} @@ -417,7 +417,7 @@ const queryResult = useLiveQuery(sharedQuery) function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils, TResult | undefined>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:324](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L324) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:332](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L332) Create a live query using a query function @@ -498,8 +498,8 @@ const todosQuery = useLiveQuery( const query = useLiveQuery((q) => q.from({ todos: todosCollection }) ) -const { data, isLoading, isError } = $derived(query) -// Now data, isLoading, and isError maintain reactivity +const { data, isLoading, isLoadingError } = $derived(query) +// Now data, isLoading, and isLoadingError maintain reactivity ``` ```ts @@ -526,7 +526,7 @@ const todosQuery = useLiveQuery((q) => // In template: // {#if todosQuery.isLoading} // <div>Loading...</div> -// {:else if todosQuery.isError} +// {:else if todosQuery.isLoadingError} // <div>Error: {todosQuery.status}</div> // {:else} // <ul> diff --git a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md index aedd412d40..f1e4a53c28 100644 --- a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md +++ b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md @@ -3,7 +3,7 @@ id: UseLiveQueryReturn title: UseLiveQueryReturn --- -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:58](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L58) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:60](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L60) Return type for useLiveQuery hook @@ -26,7 +26,7 @@ collection: Collection<T, string | number, { }>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:61](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L61) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:63](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L63) The underlying query collection instance @@ -38,33 +38,43 @@ The underlying query collection instance data: TData; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:60](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L60) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:62](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L62) Reactive array of query results in order, or single item when using findOne() *** +### health + +```ts +health: HealthNode | undefined; +``` + +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:75](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L75) + +*** + ### isCleanedUp ```ts isCleanedUp: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:70](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L70) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:72](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L72) True when query has been cleaned up *** -### isError +### isCrashed ```ts -isError: boolean; +isCrashed: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:69](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L69) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:71](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L71) -True when query encountered an error +True when local storage or an invariant broke *** @@ -74,7 +84,7 @@ True when query encountered an error isIdle: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:68](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L68) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:67](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L67) True when query hasn't started yet @@ -86,19 +96,21 @@ True when query hasn't started yet isLoading: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:63](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L63) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:65](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L65) True while initial query data is loading *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: boolean; +isLoadingError: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:66](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L66) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:69](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L69) + +True when the first load failed *** @@ -108,29 +120,21 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:66](https://github.co isReady: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:64](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L64) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:66](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L66) True when query has received first data and is ready *** -### persistedError +### isSyncError ```ts -persistedError: unknown; +isSyncError: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:67](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L67) - -*** - -### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:74](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L74) -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:65](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L65) +True when data is shown and `health` has a problem *** @@ -140,7 +144,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:65](https://github.co state: Map<string | number, T>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:59](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L59) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:61](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L61) Reactive Map of query results (key → item) @@ -152,6 +156,6 @@ Reactive Map of query results (key → item) status: CollectionStatus; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:62](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L62) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:64](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L64) Current query status diff --git a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md index a8cb0ff4e5..2210383312 100644 --- a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md +++ b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md @@ -3,7 +3,7 @@ id: UseLiveQueryReturnWithCollection title: UseLiveQueryReturnWithCollection --- -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:86](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L86) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:91](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L91) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:86](https://github.co collection: Collection<T, TKey, TUtils>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:94](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L94) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:99](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L99) *** @@ -41,7 +41,17 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:94](https://github.co data: TData; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:93](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L93) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:98](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L98) + +*** + +### health + +```ts +health: HealthNode | undefined; +``` + +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:111](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L111) *** @@ -51,17 +61,19 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:93](https://github.co isCleanedUp: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:103](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L103) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:108](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L108) *** -### isError +### isCrashed ```ts -isError: boolean; +isCrashed: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:102](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L102) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:107](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L107) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. *** @@ -71,7 +83,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:102](https://github.c isIdle: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:101](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L101) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:103](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L103) *** @@ -81,17 +93,19 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:101](https://github.c isLoading: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:96](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L96) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:101](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L101) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: boolean; +isLoadingError: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:99](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L99) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:105](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L105) + +The first load failed: there is nothing to show. *** @@ -101,27 +115,19 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:99](https://github.co isReady: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:97](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L97) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:102](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L102) *** -### persistedError +### isSyncError ```ts -persistedError: unknown; +isSyncError: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:100](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L100) - -*** - -### persistedStatus +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:110](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L110) -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:98](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L98) +The data is shown, and a sync problem or failed request is in `health`. *** @@ -131,7 +137,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:98](https://github.co state: Map<TKey, T>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:92](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L92) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:97](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L97) *** @@ -141,4 +147,4 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:92](https://github.co status: CollectionStatus; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:95](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L95) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:100](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L100) diff --git a/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md b/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md index 0d6fb132cc..a7c73d872c 100644 --- a/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md +++ b/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryReturn type ConditionalUseLiveQueryReturn<T, TData> = Omit<UseLiveQueryReturn<T, TData>, "collection" | "status"> & object; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:78](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L78) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:83](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L83) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md index 49f4f851f7..78556ba682 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -17,12 +17,6 @@ Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:51](https://g data: InferResultType<TContext>; ``` -### error - -```ts -error: unknown; -``` - ### fetchNextPage() ```ts diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index d95e565a1e..41ec0b8437 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturnWithCollection type UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils> = Omit<UseLiveQueryReturnWithCollection<TResult, TKey, TUtils, TResult[]>, "data"> & object; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:64](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L64) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:63](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L63) ## Type Declaration @@ -17,12 +17,6 @@ Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:64](https://g data: TResult[]; ``` -### error - -```ts -error: unknown; -``` - ### fetchNextPage() ```ts diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md index 4c1468d9a8..d7dcf2e91c 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveQueryConfig type UseLiveQueryConfig<TContext> = LiveQueryCollectionConfig<TContext> & object; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:108](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L108) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:116](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L116) ## Type Declaration diff --git a/docs/framework/vue/overview.md b/docs/framework/vue/overview.md index b3dda7ab32..cf9ef93de9 100644 --- a/docs/framework/vue/overview.md +++ b/docs/framework/vue/overview.md @@ -80,7 +80,7 @@ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = </template> ``` -`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in the returned `errors` ref. +`fetchNextPage()` returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change `status`; it appears as a request error in the returned `health` ref. The query must include `orderBy`. The dependency array is available only with the query-function form. You can also pass an ordered, pre-created live query diff --git a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md index cb2476a85a..a26625ebf7 100644 --- a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery<TResult, TKey, TUtils>(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>; ``` -Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L102) +Defined in: [useLiveInfiniteQuery.ts:106](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L106) Create a Vue-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. @@ -51,7 +51,7 @@ function useLiveInfiniteQuery<TContext>( deps?): UseLiveInfiniteQueryReturn<TContext>; ``` -Defined in: [useLiveInfiniteQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L113) +Defined in: [useLiveInfiniteQuery.ts:117](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L117) Create a Vue-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. diff --git a/docs/framework/vue/reference/functions/useLiveQuery.md b/docs/framework/vue/reference/functions/useLiveQuery.md index 86d6402dac..88baf178ea 100644 --- a/docs/framework/vue/reference/functions/useLiveQuery.md +++ b/docs/framework/vue/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery<TContext>(queryFn, deps?): UseLiveQueryReturn<TContext>; ``` -Defined in: [useLiveQuery.ts:177](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L177) +Defined in: [useLiveQuery.ts:168](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L168) Create a live query using a query function @@ -77,13 +77,13 @@ const { data } = useLiveQuery((q) => ```ts // Handle loading and error states in template -const { data, isLoading, isError, status } = useLiveQuery((q) => +const { data, isLoading, isLoadingError, status } = useLiveQuery((q) => q.from({ todos: todoCollection }) ) // In template: // <div v-if="isLoading">Loading...</div> -// <div v-else-if="isError">Error: {{ status }}</div> +// <div v-else-if="isLoadingError">Error: {{ status }}</div> // <ul v-else> // <li v-for="todo in data" :key="todo.id">{{ todo.text }}</li> // </ul> @@ -95,7 +95,7 @@ const { data, isLoading, isError, status } = useLiveQuery((q) => function useLiveQuery<TContext>(queryFn, deps?): ConditionalUseLiveQueryReturn<TContext>; ``` -Defined in: [useLiveQuery.ts:183](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L183) +Defined in: [useLiveQuery.ts:174](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L174) Create a live query using a query function @@ -163,13 +163,13 @@ const { data } = useLiveQuery((q) => ```ts // Handle loading and error states in template -const { data, isLoading, isError, status } = useLiveQuery((q) => +const { data, isLoading, isLoadingError, status } = useLiveQuery((q) => q.from({ todos: todoCollection }) ) // In template: // <div v-if="isLoading">Loading...</div> -// <div v-else-if="isError">Error: {{ status }}</div> +// <div v-else-if="isLoadingError">Error: {{ status }}</div> // <ul v-else> // <li v-for="todo in data" :key="todo.id">{{ todo.text }}</li> // </ul> @@ -181,7 +181,7 @@ const { data, isLoading, isError, status } = useLiveQuery((q) => function useLiveQuery<TContext>(config, deps?): UseLiveQueryReturn<TContext>; ``` -Defined in: [useLiveQuery.ts:223](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L223) +Defined in: [useLiveQuery.ts:214](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L214) Create a live query using configuration object @@ -232,13 +232,13 @@ const { data, isReady } = useLiveQuery({ ```ts // Handle all states uniformly -const { data, isLoading, isReady, isError } = useLiveQuery({ +const { data, isLoading, isReady, isLoadingError } = useLiveQuery({ query: (q) => q.from({ items: itemCollection }) }) // In template: // <div v-if="isLoading">Loading...</div> -// <div v-else-if="isError">Something went wrong</div> +// <div v-else-if="isLoadingError">Something went wrong</div> // <div v-else-if="!isReady">Preparing...</div> // <div v-else>{{ data.length }} items loaded</div> ``` @@ -249,7 +249,7 @@ const { data, isLoading, isReady, isError } = useLiveQuery({ function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils>; ``` -Defined in: [useLiveQuery.ts:268](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L268) +Defined in: [useLiveQuery.ts:259](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L259) Subscribe to an existing query collection (can be reactive) @@ -312,11 +312,11 @@ const handleToggle = (id) => { ```ts // Handle states consistently -const { data, isLoading, isError } = useLiveQuery(sharedQuery) +const { data, isLoading, isLoadingError } = useLiveQuery(sharedQuery) // In template: // <div v-if="isLoading">Loading...</div> -// <div v-else-if="isError">Error loading data</div> +// <div v-else-if="isLoadingError">Error loading data</div> // <div v-else> // <Item v-for="item in data" :key="item.id" v-bind="item" /> // </div> @@ -328,7 +328,7 @@ const { data, isLoading, isError } = useLiveQuery(sharedQuery) function useLiveQuery<TResult, TKey, TUtils>(liveQueryCollection): UseLiveQueryReturnWithSingleResultCollection<TResult, TKey, TUtils>; ``` -Defined in: [useLiveQuery.ts:279](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L279) +Defined in: [useLiveQuery.ts:270](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L270) Create a live query using a query function @@ -396,13 +396,13 @@ const { data } = useLiveQuery((q) => ```ts // Handle loading and error states in template -const { data, isLoading, isError, status } = useLiveQuery((q) => +const { data, isLoading, isLoadingError, status } = useLiveQuery((q) => q.from({ todos: todoCollection }) ) // In template: // <div v-if="isLoading">Loading...</div> -// <div v-else-if="isError">Error: {{ status }}</div> +// <div v-else-if="isLoadingError">Error: {{ status }}</div> // <ul v-else> // <li v-for="todo in data" :key="todo.id">{{ todo.text }}</li> // </ul> diff --git a/docs/framework/vue/reference/index.md b/docs/framework/vue/reference/index.md index 6115c8aa06..6b46272af3 100644 --- a/docs/framework/vue/reference/index.md +++ b/docs/framework/vue/reference/index.md @@ -7,6 +7,7 @@ title: "@tanstack/vue-db" - [UseLiveInfiniteQueryReturn](interfaces/UseLiveInfiniteQueryReturn.md) - [UseLiveInfiniteQueryReturnWithCollection](interfaces/UseLiveInfiniteQueryReturnWithCollection.md) +- [UseLiveQueryFlags](interfaces/UseLiveQueryFlags.md) - [UseLiveQueryReturn](interfaces/UseLiveQueryReturn.md) - [UseLiveQueryReturnWithCollection](interfaces/UseLiveQueryReturnWithCollection.md) - [UseLiveQueryReturnWithSingleResultCollection](interfaces/UseLiveQueryReturnWithSingleResultCollection.md) diff --git a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md index ee516e947c..8fdb5ba46a 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md @@ -33,23 +33,13 @@ Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/mai *** -### error - -```ts -error: ComputedRef<unknown>; -``` - -Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) - -*** - ### fetchNextPage() ```ts fetchNextPage: () => Promise<void>; ``` -Defined in: [useLiveInfiniteQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L67) +Defined in: [useLiveInfiniteQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L69) #### Returns @@ -63,7 +53,17 @@ Defined in: [useLiveInfiniteQuery.ts:67](https://github.com/TanStack/db/blob/mai hasNextPage: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L68) +Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) + +*** + +### health + +```ts +health: ComputedRef<HealthNode | undefined>; +``` + +Defined in: [useLiveInfiniteQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L72) *** @@ -77,14 +77,16 @@ Defined in: [useLiveInfiniteQuery.ts:64](https://github.com/TanStack/db/blob/mai *** -### isError +### isCrashed ```ts -isError: ComputedRef<boolean>; +isCrashed: ComputedRef<boolean>; ``` Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L63) +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + *** ### isFetchingNextPage @@ -93,7 +95,7 @@ Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/mai isFetchingNextPage: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L69) +Defined in: [useLiveInfiniteQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L71) *** @@ -103,7 +105,7 @@ Defined in: [useLiveInfiniteQuery.ts:69](https://github.com/TanStack/db/blob/mai isIdle: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:62](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L62) +Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L59) *** @@ -117,13 +119,15 @@ Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/mai *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: ComputedRef<boolean>; +isLoadingError: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L60) +Defined in: [useLiveInfiniteQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L61) + +The first load failed: there is nothing to show. *** @@ -137,43 +141,35 @@ Defined in: [useLiveInfiniteQuery.ts:58](https://github.com/TanStack/db/blob/mai *** -### pageParams +### isSyncError ```ts -pageParams: ComputedRef<number[]>; +isSyncError: ComputedRef<boolean>; ``` Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L66) -*** - -### pages - -```ts -pages: ComputedRef<InferResultType<TContext>[number][][]>; -``` - -Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L65) +The data is shown, and a sync problem or failed request is in `health`. *** -### persistedError +### pageParams ```ts -persistedError: ComputedRef<unknown>; +pageParams: ComputedRef<number[]>; ``` -Defined in: [useLiveInfiniteQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L61) +Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L68) *** -### persistedStatus +### pages ```ts -persistedStatus: ComputedRef<LiveQueryPersistedStatus>; +pages: ComputedRef<InferResultType<TContext>[number][][]>; ``` -Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L59) +Defined in: [useLiveInfiniteQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L67) *** diff --git a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md index 5c8d419dcd..4e12c6eac0 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md @@ -3,7 +3,7 @@ id: UseLiveInfiniteQueryReturnWithCollection title: UseLiveInfiniteQueryReturnWithCollection --- -Defined in: [useLiveInfiniteQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L73) +Defined in: [useLiveInfiniteQuery.ts:75](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L75) ## Type Parameters @@ -27,7 +27,7 @@ Defined in: [useLiveInfiniteQuery.ts:73](https://github.com/TanStack/db/blob/mai collection: ComputedRef<Collection<TResult, TKey, TUtils, StandardSchemaV1<unknown, unknown>, TResult>>; ``` -Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L80) +Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L82) *** @@ -37,17 +37,7 @@ Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/mai data: ComputedRef<TResult[]>; ``` -Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L79) - -*** - -### error - -```ts -error: ComputedRef<unknown>; -``` - -Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L95) +Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L81) *** @@ -57,7 +47,7 @@ Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/mai fetchNextPage: () => Promise<void>; ``` -Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L92) +Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L96) #### Returns @@ -71,7 +61,17 @@ Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/mai hasNextPage: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L93) +Defined in: [useLiveInfiniteQuery.ts:97](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L97) + +*** + +### health + +```ts +health: ComputedRef<HealthNode | undefined>; +``` + +Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L99) *** @@ -81,17 +81,19 @@ Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/mai isCleanedUp: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L89) +Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L91) *** -### isError +### isCrashed ```ts -isError: ComputedRef<boolean>; +isCrashed: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L88) +Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L90) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. *** @@ -101,7 +103,7 @@ Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/mai isFetchingNextPage: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L94) +Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L98) *** @@ -111,7 +113,7 @@ Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/mai isIdle: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L87) +Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L86) *** @@ -121,17 +123,19 @@ Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/mai isLoading: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L82) +Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L84) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: ComputedRef<boolean>; +isLoadingError: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L85) +Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L88) + +The first load failed: there is nothing to show. *** @@ -141,47 +145,39 @@ Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/mai isReady: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L83) +Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L85) *** -### pageParams +### isSyncError ```ts -pageParams: ComputedRef<number[]>; +isSyncError: ComputedRef<boolean>; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L91) - -*** - -### pages - -```ts -pages: ComputedRef<TResult[][]>; -``` +Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L93) -Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L90) +The data is shown, and a sync problem or failed request is in `health`. *** -### persistedError +### pageParams ```ts -persistedError: ComputedRef<unknown>; +pageParams: ComputedRef<number[]>; ``` -Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L86) +Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L95) *** -### persistedStatus +### pages ```ts -persistedStatus: ComputedRef<LiveQueryPersistedStatus>; +pages: ComputedRef<TResult[][]>; ``` -Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L84) +Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L94) *** @@ -191,7 +187,7 @@ Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/mai state: ComputedRef<Map<TKey, TResult>>; ``` -Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L78) +Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L80) *** @@ -201,4 +197,4 @@ Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/mai status: ComputedRef<CollectionStatus>; ``` -Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L81) +Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L83) diff --git a/docs/framework/vue/reference/interfaces/UseLiveQueryFlags.md b/docs/framework/vue/reference/interfaces/UseLiveQueryFlags.md new file mode 100644 index 0000000000..14fe3b4b13 --- /dev/null +++ b/docs/framework/vue/reference/interfaces/UseLiveQueryFlags.md @@ -0,0 +1,110 @@ +--- +id: UseLiveQueryFlags +title: UseLiveQueryFlags +--- + +Defined in: [useLiveQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L57) + +Status, persistence, and error refs every `useLiveQuery` result has. + +## Extended by + +- [`UseLiveQueryReturn`](UseLiveQueryReturn.md) +- [`UseLiveQueryReturnWithCollection`](UseLiveQueryReturnWithCollection.md) +- [`UseLiveQueryReturnWithSingleResultCollection`](UseLiveQueryReturnWithSingleResultCollection.md) + +## Properties + +### health + +```ts +health: ComputedRef<HealthNode | undefined>; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L69) + +*** + +### isCleanedUp + +```ts +isCleanedUp: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L66) + +*** + +### isCrashed + +```ts +isCrashed: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L65) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +*** + +### isIdle + +```ts +isIdle: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L61) + +*** + +### isLoading + +```ts +isLoading: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L59) + +*** + +### isLoadingError + +```ts +isLoadingError: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L63) + +The first load failed: there is nothing to show. + +*** + +### isReady + +```ts +isReady: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L60) + +*** + +### isSyncError + +```ts +isSyncError: ComputedRef<boolean>; +``` + +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L68) + +The data is shown, and a sync problem or failed request is in `health`. + +*** + +### status + +```ts +status: ComputedRef<CollectionStatus>; +``` + +Defined in: [useLiveQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L58) diff --git a/docs/framework/vue/reference/interfaces/UseLiveQueryReturn.md b/docs/framework/vue/reference/interfaces/UseLiveQueryReturn.md index 51a23beac0..a7834e9f93 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveQueryReturn.md +++ b/docs/framework/vue/reference/interfaces/UseLiveQueryReturn.md @@ -3,9 +3,13 @@ id: UseLiveQueryReturn title: UseLiveQueryReturn --- -Defined in: [useLiveQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L56) +Defined in: [useLiveQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L72) -Return type for useLiveQuery hook +Status, persistence, and error refs every `useLiveQuery` result has. + +## Extends + +- [`UseLiveQueryFlags`](UseLiveQueryFlags.md) ## Type Parameters @@ -22,9 +26,7 @@ collection: ComputedRef<Collection<{ [K in string | number | symbol]: ResultValu }, StandardSchemaV1<unknown, unknown>, { [K in string | number | symbol]: ResultValue<TContext>[K] }>>; ``` -Defined in: [useLiveQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L59) - -The underlying query collection instance +Defined in: [useLiveQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L77) *** @@ -34,9 +36,21 @@ The underlying query collection instance data: ComputedRef<InferResultType<TContext>>; ``` -Defined in: [useLiveQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L58) +Defined in: [useLiveQuery.ts:76](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L76) + +*** + +### health + +```ts +health: ComputedRef<HealthNode | undefined>; +``` -Reactive array of query results in order, or single result for findOne queries +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`health`](UseLiveQueryFlags.md#health) *** @@ -46,21 +60,27 @@ Reactive array of query results in order, or single result for findOne queries isCleanedUp: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L68) +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L66) + +#### Inherited from -True when query has been cleaned up +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isCleanedUp`](UseLiveQueryFlags.md#iscleanedup) *** -### isError +### isCrashed ```ts -isError: ComputedRef<boolean>; +isCrashed: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L67) +Defined in: [useLiveQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L65) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +#### Inherited from -True when query encountered an error +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isCrashed`](UseLiveQueryFlags.md#iscrashed) *** @@ -70,9 +90,11 @@ True when query encountered an error isIdle: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L66) +Defined in: [useLiveQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L61) -True when query hasn't started yet +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isIdle`](UseLiveQueryFlags.md#isidle) *** @@ -82,19 +104,27 @@ True when query hasn't started yet isLoading: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L61) +Defined in: [useLiveQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L59) -True while initial query data is loading +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isLoading`](UseLiveQueryFlags.md#isloading) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: ComputedRef<boolean>; +isLoadingError: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:64](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L64) +Defined in: [useLiveQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L63) + +The first load failed: there is nothing to show. + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isLoadingError`](UseLiveQueryFlags.md#isloadingerror) *** @@ -104,29 +134,27 @@ Defined in: [useLiveQuery.ts:64](https://github.com/TanStack/db/blob/main/packag isReady: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:62](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L62) +Defined in: [useLiveQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L60) -True when query has received first data and is ready +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isReady`](UseLiveQueryFlags.md#isready) *** -### persistedError +### isSyncError ```ts -persistedError: ComputedRef<unknown>; +isSyncError: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L65) - -*** +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L68) -### persistedStatus +The data is shown, and a sync problem or failed request is in `health`. -```ts -persistedStatus: ComputedRef<LiveQueryPersistedStatus>; -``` +#### Inherited from -Defined in: [useLiveQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L63) +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isSyncError`](UseLiveQueryFlags.md#issyncerror) *** @@ -136,9 +164,7 @@ Defined in: [useLiveQuery.ts:63](https://github.com/TanStack/db/blob/main/packag state: ComputedRef<Map<string | number, { [K in string | number | symbol]: ResultValue<TContext>[K] }>>; ``` -Defined in: [useLiveQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L57) - -Reactive Map of query results (key → item) +Defined in: [useLiveQuery.ts:75](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L75) *** @@ -148,6 +174,8 @@ Reactive Map of query results (key → item) status: ComputedRef<CollectionStatus>; ``` -Defined in: [useLiveQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L60) +Defined in: [useLiveQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L58) + +#### Inherited from -Current query status +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`status`](UseLiveQueryFlags.md#status) diff --git a/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithCollection.md b/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithCollection.md index ff6f9a6cb0..af3ba691ea 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithCollection.md +++ b/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithCollection.md @@ -3,7 +3,13 @@ id: UseLiveQueryReturnWithCollection title: UseLiveQueryReturnWithCollection --- -Defined in: [useLiveQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L89) +Defined in: [useLiveQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L98) + +Status, persistence, and error refs every `useLiveQuery` result has. + +## Extends + +- [`UseLiveQueryFlags`](UseLiveQueryFlags.md) ## Type Parameters @@ -27,7 +33,7 @@ Defined in: [useLiveQuery.ts:89](https://github.com/TanStack/db/blob/main/packag collection: ComputedRef<Collection<T, TKey, TUtils, StandardSchemaV1<unknown, unknown>, T>>; ``` -Defined in: [useLiveQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L96) +Defined in: [useLiveQuery.ts:105](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L105) *** @@ -37,7 +43,21 @@ Defined in: [useLiveQuery.ts:96](https://github.com/TanStack/db/blob/main/packag data: ComputedRef<T[]>; ``` -Defined in: [useLiveQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L95) +Defined in: [useLiveQuery.ts:104](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L104) + +*** + +### health + +```ts +health: ComputedRef<HealthNode | undefined>; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`health`](UseLiveQueryFlags.md#health) *** @@ -47,17 +67,27 @@ Defined in: [useLiveQuery.ts:95](https://github.com/TanStack/db/blob/main/packag isCleanedUp: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:105](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L105) +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L66) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isCleanedUp`](UseLiveQueryFlags.md#iscleanedup) *** -### isError +### isCrashed ```ts -isError: ComputedRef<boolean>; +isCrashed: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:104](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L104) +Defined in: [useLiveQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L65) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isCrashed`](UseLiveQueryFlags.md#iscrashed) *** @@ -67,7 +97,11 @@ Defined in: [useLiveQuery.ts:104](https://github.com/TanStack/db/blob/main/packa isIdle: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:103](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L103) +Defined in: [useLiveQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L61) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isIdle`](UseLiveQueryFlags.md#isidle) *** @@ -77,17 +111,27 @@ Defined in: [useLiveQuery.ts:103](https://github.com/TanStack/db/blob/main/packa isLoading: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L98) +Defined in: [useLiveQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L59) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isLoading`](UseLiveQueryFlags.md#isloading) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: ComputedRef<boolean>; +isLoadingError: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L101) +Defined in: [useLiveQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L63) + +The first load failed: there is nothing to show. + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isLoadingError`](UseLiveQueryFlags.md#isloadingerror) *** @@ -97,27 +141,27 @@ Defined in: [useLiveQuery.ts:101](https://github.com/TanStack/db/blob/main/packa isReady: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L99) +Defined in: [useLiveQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L60) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isReady`](UseLiveQueryFlags.md#isready) *** -### persistedError +### isSyncError ```ts -persistedError: ComputedRef<unknown>; +isSyncError: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:102](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L102) - -*** +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L68) -### persistedStatus +The data is shown, and a sync problem or failed request is in `health`. -```ts -persistedStatus: ComputedRef<LiveQueryPersistedStatus>; -``` +#### Inherited from -Defined in: [useLiveQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L100) +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isSyncError`](UseLiveQueryFlags.md#issyncerror) *** @@ -127,7 +171,7 @@ Defined in: [useLiveQuery.ts:100](https://github.com/TanStack/db/blob/main/packa state: ComputedRef<Map<TKey, T>>; ``` -Defined in: [useLiveQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L94) +Defined in: [useLiveQuery.ts:103](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L103) *** @@ -137,4 +181,8 @@ Defined in: [useLiveQuery.ts:94](https://github.com/TanStack/db/blob/main/packag status: ComputedRef<CollectionStatus>; ``` -Defined in: [useLiveQuery.ts:97](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L97) +Defined in: [useLiveQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L58) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`status`](UseLiveQueryFlags.md#status) diff --git a/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithSingleResultCollection.md b/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithSingleResultCollection.md index 56eab708f4..1a51882b20 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithSingleResultCollection.md +++ b/docs/framework/vue/reference/interfaces/UseLiveQueryReturnWithSingleResultCollection.md @@ -5,6 +5,12 @@ title: UseLiveQueryReturnWithSingleResultCollection Defined in: [useLiveQuery.ts:108](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L108) +Status, persistence, and error refs every `useLiveQuery` result has. + +## Extends + +- [`UseLiveQueryFlags`](UseLiveQueryFlags.md) + ## Type Parameters ### T @@ -41,23 +47,47 @@ Defined in: [useLiveQuery.ts:114](https://github.com/TanStack/db/blob/main/packa *** +### health + +```ts +health: ComputedRef<HealthNode | undefined>; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`health`](UseLiveQueryFlags.md#health) + +*** + ### isCleanedUp ```ts isCleanedUp: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:124](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L124) +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L66) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isCleanedUp`](UseLiveQueryFlags.md#iscleanedup) *** -### isError +### isCrashed ```ts -isError: ComputedRef<boolean>; +isCrashed: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:123](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L123) +Defined in: [useLiveQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L65) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isCrashed`](UseLiveQueryFlags.md#iscrashed) *** @@ -67,7 +97,11 @@ Defined in: [useLiveQuery.ts:123](https://github.com/TanStack/db/blob/main/packa isIdle: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:122](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L122) +Defined in: [useLiveQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L61) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isIdle`](UseLiveQueryFlags.md#isidle) *** @@ -77,17 +111,27 @@ Defined in: [useLiveQuery.ts:122](https://github.com/TanStack/db/blob/main/packa isLoading: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:117](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L117) +Defined in: [useLiveQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L59) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isLoading`](UseLiveQueryFlags.md#isloading) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: ComputedRef<boolean>; +isLoadingError: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:120](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L120) +Defined in: [useLiveQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L63) + +The first load failed: there is nothing to show. + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isLoadingError`](UseLiveQueryFlags.md#isloadingerror) *** @@ -97,27 +141,27 @@ Defined in: [useLiveQuery.ts:120](https://github.com/TanStack/db/blob/main/packa isReady: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:118](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L118) +Defined in: [useLiveQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L60) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isReady`](UseLiveQueryFlags.md#isready) *** -### persistedError +### isSyncError ```ts -persistedError: ComputedRef<unknown>; +isSyncError: ComputedRef<boolean>; ``` -Defined in: [useLiveQuery.ts:121](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L121) - -*** +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L68) -### persistedStatus +The data is shown, and a sync problem or failed request is in `health`. -```ts -persistedStatus: ComputedRef<LiveQueryPersistedStatus>; -``` +#### Inherited from -Defined in: [useLiveQuery.ts:119](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L119) +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`isSyncError`](UseLiveQueryFlags.md#issyncerror) *** @@ -137,4 +181,8 @@ Defined in: [useLiveQuery.ts:113](https://github.com/TanStack/db/blob/main/packa status: ComputedRef<CollectionStatus>; ``` -Defined in: [useLiveQuery.ts:116](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L116) +Defined in: [useLiveQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L58) + +#### Inherited from + +[`UseLiveQueryFlags`](UseLiveQueryFlags.md).[`status`](UseLiveQueryFlags.md#status) diff --git a/docs/framework/vue/reference/type-aliases/ConditionalUseLiveQueryReturn.md b/docs/framework/vue/reference/type-aliases/ConditionalUseLiveQueryReturn.md index 7438e96eb3..4cf396068d 100644 --- a/docs/framework/vue/reference/type-aliases/ConditionalUseLiveQueryReturn.md +++ b/docs/framework/vue/reference/type-aliases/ConditionalUseLiveQueryReturn.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryReturn type ConditionalUseLiveQueryReturn<TContext> = Omit<UseLiveQueryReturn<TContext>, "data" | "collection" | "status"> & object; ``` -Defined in: [useLiveQuery.ts:76](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L76) +Defined in: [useLiveQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L85) ## Type Declaration diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 433cfb2d8e..0f5643034f 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -71,7 +71,7 @@ For more details on schema validation and type transformations, see the [Schemas ## Query Collection Error Tracking -Query Collections report failures through the error tree, like every other +Query Collections report failures through the health tree, like every other Collection. A `queryFn` failure before the first successful result fails the first load: the status is `error`, and `preload()` rejects with the cause. Set `retry` so that a short outage is retried inside that first load. After the @@ -186,10 +186,10 @@ are waiting for replay with `AbortError`. An app that shows synced data asks two questions: 1. **Can I show the data?** Read `status`. -2. **Is the data on screen still being kept up to date?** Read `isSyncError` and `errors`. +2. **Is the data on screen still being kept up to date?** Read `isSyncError` and `health`. TanStack DB answers them separately. Once a query has shown data, a network or -backend failure never takes the data off the screen. It shows up in `errors`, +backend failure never takes the data off the screen. It shows up in `health`, and the rows stay. ```tsx @@ -203,7 +203,7 @@ function TaskList() { isLoadingError, isCrashed, isSyncError, - errors, + health, collection, } = useLiveQuery((q) => q @@ -218,7 +218,7 @@ function TaskList() { return ( <> {isSyncError && ( - <SyncBanner errors={errors} onReload={() => collection.reload()} /> + <SyncBanner health={health} onReload={() => collection.reload()} /> )} {data.map((row) => ( <TaskRow key={row.task.id} {...row} /> @@ -243,7 +243,7 @@ Two rules make `status` predictable: - **After a query has shown data, only a crash changes its status.** A dropped connection, a failed refetch, or a backend that stops answering appears in - `errors`. `isLoadingError` therefore always means "nothing to show yet". + `health`. `isLoadingError` therefore always means "nothing to show yet". - **A query takes the worst status of its sources until it is ready.** If any source fails its first load, the query's status is `error`, and `preload()` rejects with the cause. @@ -251,12 +251,12 @@ Two rules make `status` predictable: `isError` is removed. Use `isLoadingError` and `isCrashed`, which need different handling. -### Sync errors: the `errors` tree +### Sync errors: the `health` tree -`errors` has one node for the query itself and one for each source in the +`health` has one node for the query itself and one for each source in the query, in query order. The tree's shape is fixed by the query, so you can render, log, or send it to monitoring. A disabled query has no query -definition, so its `errors` is `undefined`. +definition, so its `health` is `undefined`. ```ts { @@ -288,7 +288,7 @@ Each problem is `{ name, message, code?, at, cause }`. `cause` holds the exact value the adapter reported, for example the `Response` or the error your `queryFn` threw. Every other field is plain JSON. A node keeps its object identity until something in its part of the tree changes, so `===` and -`useMemo` work on it. Server-side rendering does not transfer `errors`; the +`useMemo` work on it. Server-side rendering does not transfer `health`; the client builds its own tree from its own sync. `failureReason` says that the adapter is trying, not that trying will help. An @@ -296,23 +296,23 @@ expired token gives a `failureReason` on every refetch until the user signs in. Use `code` or `cause` for cases like this: ```tsx -import { findProblems, type ErrorTreeNode } from "@tanstack/db" +import { findProblems, type HealthNode } from "@tanstack/db" function SyncBanner({ - errors, + health, onReload, }: { - errors: ErrorTreeNode + health: HealthNode onReload: () => void }) { - const [first] = findProblems(errors) // nodes with a problem, in query order + const [first] = findProblems(health) // nodes with a problem, in query order if (first?.failureReason?.code === "401") return <SignInAgain /> if (first?.error) return <UpdatesStopped onReload={onReload} /> return <Reconnecting /> } ``` -`findProblems(errors)` walks the tree and returns every node that has a +`findProblems(health)` walks the tree and returns every node that has a problem of its own, in query order. Use it for the message on a failure screen too: when `isLoadingError` is true, the first node it returns holds the first load failure in its `error`. @@ -329,12 +329,12 @@ does not need a live query: ```ts todos.status // 'loading' | 'ready' | 'error' | 'crashed' | ... -todos.errors // one node: { status, failureReason, error, reloading, ... } -todos.on("errors:change", ({ collection }) => report(collection.errors)) +todos.health // one node: { status, failureReason, error, reloading, ... } +todos.on("health:change", ({ collection }) => report(collection.health)) await todos.reload() ``` -A live query is a Collection too; its `errors` is the whole tree. +A live query is a Collection too; its `health` is the whole tree. ### Failed requests for more data @@ -345,10 +345,10 @@ shown stay. The error clears when the data loads, or when the query no longer needs it, for example after the user scrolls back. ```tsx -const { data, errors, fetchNextPage } = useLiveInfiniteQuery(/* ... */) +const { data, health, fetchNextPage } = useLiveInfiniteQuery(/* ... */) { - errors?.error && ( + health?.error && ( <button onClick={() => fetchNextPage()}>Retry loading more</button> ) } @@ -437,7 +437,7 @@ empty restore keeps it `loading`, and a failed restore crashes it. See An Effect ends when a source it reads fails its first load or crashes, or when a load it needs fails; it calls `onSourceError(error)`. After its initial -load, a sync problem on a source calls `onErrorsChange(errors)` with the same +load, a sync problem on a source calls `onHealthChange(health)` with the same tree a live query returns, and the Effect keeps running. ```ts @@ -446,8 +446,8 @@ createEffect({ onBatch: (events) => { /* ... */ }, - onErrorsChange: (errors) => { - if (errors.hasErrors) pauseAutomations() + onHealthChange: (health) => { + if (health.hasErrors) pauseAutomations() else resumeAutomations() }, }) @@ -458,7 +458,7 @@ createEffect({ `useLiveSuspenseQuery` suspends while `loading` and throws the first load's cause to the nearest error boundary for `error`. It throws for `crashed` at any time. Sync problems after the first data never reach the boundary; read -`errors` from the hook, as in TanStack Query. +`health` from the hook, as in TanStack Query. ```tsx import { useLiveSuspenseQuery } from "@tanstack/react-db" @@ -548,10 +548,8 @@ sync: ({ begin, write, commit, markReady, reportError, fail, reportHealthy }) => | node `instances` | removed | | `cleanup()` then `preload()` to recover | `reload()` | | `cleanup()` with live queries mounted | rejects; unmount first or `cleanup({ dependents: true })` | -| `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()` | `status` and `errors` | -| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `errors`, `reload()` | -| `collection.health`, `health:change` | `collection.errors`, `errors:change` | -| Effect `onHealthChange` | `onErrorsChange` | +| `persistedStatus`, `isPersistedReady`, `persistedError`, `preloadForInitialRender()` | `status` and `health` | +| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `health`, `reload()` | ## Transaction Error Handling @@ -903,17 +901,17 @@ start it again. See [When a Collection crashes](#when-a-collection-crashes). ### Graceful Degradation After the first ready, Collections keep working with their rows when sync -fails. The error tree tells you which source has the problem and whether its +fails. The health tree tells you which source has the problem and whether its sync adapter is still trying: ```tsx import { findProblems } from "@tanstack/db" const TodoApp = () => { - const { data, errors, collection } = useLiveQuery((query) => + const { data, health, collection } = useLiveQuery((query) => query.from({ todos: todoCollection }) ) - const [problem] = errors ? findProblems(errors) : [] + const [problem] = health ? findProblems(health) : [] return ( <div> @@ -1095,7 +1093,7 @@ Thrown when calling `commit()` on a sync transaction that's already committed. 2. **Import specific error types** - Import only the error classes you need for better tree-shaking 3. **Always handle SchemaValidationError** - Provide clear feedback for validation failures -4. **Check both questions** - Use `status` (`isLoading`, `isLoadingError`, `isCrashed`) for whether data can be shown, and `isSyncError` or `errors` for whether it may be stale +4. **Check both questions** - Use `status` (`isLoading`, `isLoadingError`, `isCrashed`) for whether data can be shown, and `isSyncError` or `health` for whether it may be stale 5. **Handle transaction promises** - Always handle `when('settled')` rejections ## Example: Complete Error Handling diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 9475d77be5..435fd46e85 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -426,14 +426,14 @@ function UserList() { ### Stale Data and the Error Tree -Every live query hook also returns `errors`, an error tree with one node for +Every live query hook also returns `health`, a health tree with one node for the query and one for each source alias. After the query is ready, a source that loses its sync connection keeps its rows, and `status` stays `ready`. The -problem appears only in `errors`: +problem appears only in `health`: ```tsx function UserList() { - const { data, isLoading, isError, errors } = useLiveQuery((q) => + const { data, isLoading, isError, health } = useLiveQuery((q) => q.from({ user: usersCollection }), ) @@ -442,7 +442,7 @@ function UserList() { return ( <> - {errors?.hasErrors && <div>Users may be out of date</div>} + {health?.hasErrors && <div>Users may be out of date</div>} <ul> {data.map((user) => ( <li key={user.id}>{user.name}</li> @@ -455,7 +455,7 @@ function UserList() { > **Important:** `isError` means the query must restart: its initial load > failed, or a source crashed. To show a "data may be out of date" banner, -> check `errors.hasErrors`. `useLiveSuspenseQuery` also returns `errors`, and +> check `health.hasErrors`. `useLiveSuspenseQuery` also returns `health`, and > after its first render it never throws for these problems. See [Collection Status and Error States](./error-handling.md#collection-status-and-error-states) @@ -508,7 +508,7 @@ const { data, isEnabled, status } = useLiveQuery({ The top-level callback form supports the same behavior. When the query is disabled: - `status` is `'disabled'` -- `data`, `state`, `collection`, and `errors` are `undefined` +- `data`, `state`, `collection`, and `health` are `undefined` - `isEnabled` is `false` - `isReady` is `true` - `isLoading`, `isIdle`, `isError`, and `isCleanedUp` are all `false` @@ -2357,7 +2357,7 @@ const effect = createEffect({ | `onExit` | `(event, ctx) => void \| Promise<void>` (optional) | Called once for each row exiting the query result. | | `onBatch` | `(events, ctx) => void \| Promise<void>` (optional) | Called once per graph run with the full unfiltered batch of delta events. | | `onError` | `(error, event) => void` (optional) | Called when `onEnter`, `onUpdate`, `onExit`, or `onBatch` throws or rejects. | -| `onErrorsChange` | `(errors) => void` (optional) | Called with the Effect's error tree when a source's sync errors or a request error change after the Effect's initial load. | +| `onHealthChange` | `(health) => void` (optional) | Called with the Effect's health tree when a source's sync errors or a request error change after the Effect's initial load. | | `onSourceError` | `(error) => void` (optional) | Called when a source collection fails its first load, crashes, or is cleaned up with `{ dependents: true }`. The effect is automatically disposed after this fires. If not provided, the error is logged to `console.error`. | | `skipInitial` | `boolean` (optional) | When `true`, deltas from the initial data load are suppressed. Only subsequent changes fire handlers. Defaults to `false`. | @@ -2479,7 +2479,7 @@ createEffect({ }) ``` -If a source collection fails its first load, crashes, or is cleaned up with `{ dependents: true }`, the effect automatically disposes itself. Use `onSourceError` to handle this. Other problems after the effect's initial load do not dispose it; they reach `onErrorsChange`: +If a source collection fails its first load, crashes, or is cleaned up with `{ dependents: true }`, the effect automatically disposes itself. Use `onSourceError` to handle this. Other problems after the effect's initial load do not dispose it; they reach `onHealthChange`: ```ts createEffect({ diff --git a/docs/guides/offline-transactions.md b/docs/guides/offline-transactions.md index 17b512e26d..758904bb32 100644 --- a/docs/guides/offline-transactions.md +++ b/docs/guides/offline-transactions.md @@ -348,7 +348,7 @@ void transaction.when('settled').catch((error) => console.error(error)) For the leader, `waitForInit()` waits for the initial outbox read and optimistic restoration attempt. It does not make `todos` ready. The separate `preload()` call starts Collection loading without blocking offline actions. A screen that needs initial rows can await `todos.preload()` and handle a rejection. -The final `refetch()` asks the server for current rows after a successful write. By default, it can resolve when that read fails. The offline transaction can then settle successfully because the POST succeeded. That read error does not itself request an outbox retry. Check `todos.health` (or the `errors` tree of a live query over it) to report or retry the read. +The final `refetch()` asks the server for current rows after a successful write. By default, it can resolve when that read fails. The offline transaction can then settle successfully because the POST succeeded. That read error does not itself request an outbox retry. Check `todos.health` (or the `health` tree of a live query over it) to report or retry the read. The [React Native offline transactions example](https://github.com/TanStack/db/tree/main/examples/react-native/offline-transactions) shows the full app structure. Its API client does not yet send an idempotency key, so use the request pattern above when you adapt it. The [shopping list example](https://github.com/TanStack/db/tree/main/examples/react-native/shopping-list) also shows Electric sync. diff --git a/docs/guides/sqlite-persistence.md b/docs/guides/sqlite-persistence.md index 6951c037bc..a5827ee701 100644 --- a/docs/guides/sqlite-persistence.md +++ b/docs/guides/sqlite-persistence.md @@ -266,7 +266,7 @@ function TodosView() { These rules keep `ready` meaningful: - **An empty restore waits for the network.** The Collection stays `loading` until the sync adapter answers, so an empty list always means "no rows". A Collection that last synced to zero rows also waits, because the restore cannot tell the two cases apart. -- **The restore comes first.** If the sync adapter answers before the restore completes, the answer waits for the restore. A remote failure then opens the Collection from restored rows and shows the failure as a sync error (`isSyncError`, `collection.errors.error`). Without restored rows, the first load fails (`status: 'error'`). +- **The restore comes first.** If the sync adapter answers before the restore completes, the answer waits for the restore. A remote failure then opens the Collection from restored rows and shows the failure as a sync error (`isSyncError`, `collection.health.error`). Without restored rows, the first load fails (`status: 'error'`). - **A failed restore is a crash.** The Collection becomes `crashed`, because local storage cannot serve reads. - **The deadline does not stop network sync.** Network changes apply over the restored rows when they arrive. @@ -352,7 +352,7 @@ The Node package prunes its applied transaction log by default. Its [README](htt An unavailable browser storage capability raises `PersistenceUnavailableError`. A durable write failure raises `PersistedCollectionDurabilityError` and crashes the Collection: it moves to its error state even after it was ready, and every live query and Effect over it fails too. Restart it with `cleanup()` and `preload()`. -Failures from the wrapped sync adapter keep their usual meaning. Before the Collection is ready, the adapter's `fail` fails the first load (see [Render after SQLite restore](#render-after-sqlite-restore) for `initialRender`). After it, the adapter's `reportError`, `reportHealthy`, and `fail` appear in `collection.errors` and in each live query's `errors` tree, and the persisted rows stay. A failure of persistence itself crashes the Collection. In a follower tab, a failed request routed to the leader appears as a request error on the query that asked for the data. +Failures from the wrapped sync adapter keep their usual meaning. Before the Collection is ready, the adapter's `fail` fails the first load (see [Render after SQLite restore](#render-after-sqlite-restore) for `initialRender`). After it, the adapter's `reportError`, `reportHealthy`, and `fail` appear in `collection.health` and in each live query's `health` tree, and the persisted rows stay. A failure of persistence itself crashes the Collection. In a follower tab, a failed request routed to the leader appears as a request error on the query that asked for the data. If a coordinated write loses its response, the result can be uncertain. `IndeterminateCommitError` means the application must check the durable state before it retries that write. diff --git a/docs/reference/@tanstack/namespaces/IR/functions/collectCollectionSources.md b/docs/reference/@tanstack/namespaces/IR/functions/collectCollectionSources.md index 9ce541176e..64b8350326 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/collectCollectionSources.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/collectCollectionSources.md @@ -7,7 +7,7 @@ title: collectCollectionSources function collectCollectionSources(query): CollectionRef[]; ``` -Defined in: [packages/db/src/query/ir.ts:384](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L384) +Defined in: [packages/db/src/query/ir.ts:392](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L392) Returns concrete Collection sources after requiring client binding. diff --git a/docs/reference/@tanstack/namespaces/IR/functions/collectSourceRefs.md b/docs/reference/@tanstack/namespaces/IR/functions/collectSourceRefs.md index 586bd48327..d53ecac8db 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/collectSourceRefs.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/collectSourceRefs.md @@ -4,7 +4,7 @@ title: collectSourceRefs --- ```ts -function collectSourceRefs(query): CollectionSourceRef[]; +function collectSourceRefs(query, includeSourceIds?): CollectionSourceRef[]; ``` Defined in: [packages/db/src/query/ir.ts:335](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L335) @@ -17,6 +17,12 @@ Returns each lexical Collection or descriptor source in a query tree once. [`QueryIR`](../interfaces/QueryIR.md) +### includeSourceIds? + +`Set`\<`string`\> + +Receives the IDs of sources first reached through an include. + ## Returns [`CollectionSourceRef`](../type-aliases/CollectionSourceRef.md)[] diff --git a/docs/reference/@tanstack/namespaces/IR/functions/createResidualWhere.md b/docs/reference/@tanstack/namespaces/IR/functions/createResidualWhere.md index 048dc97d13..952e2234c6 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/createResidualWhere.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/createResidualWhere.md @@ -7,7 +7,7 @@ title: createResidualWhere function createResidualWhere(expression): Where; ``` -Defined in: [packages/db/src/query/ir.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L429) +Defined in: [packages/db/src/query/ir.ts:437](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L437) Create a residual Where clause from an expression diff --git a/docs/reference/@tanstack/namespaces/IR/functions/followRef.md b/docs/reference/@tanstack/namespaces/IR/functions/followRef.md index 9dacda480c..52d19ee505 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/followRef.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/followRef.md @@ -17,7 +17,7 @@ function followRef( }; ``` -Defined in: [packages/db/src/query/ir.ts:477](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L477) +Defined in: [packages/db/src/query/ir.ts:485](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L485) Follows the given reference in a query until its finds the root field the reference points to. diff --git a/docs/reference/@tanstack/namespaces/IR/functions/getFromSources.md b/docs/reference/@tanstack/namespaces/IR/functions/getFromSources.md index c1c20db12c..ff526ef0a0 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/getFromSources.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/getFromSources.md @@ -9,7 +9,7 @@ function getFromSources(from): ( | QueryRef)[]; ``` -Defined in: [packages/db/src/query/ir.ts:436](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L436) +Defined in: [packages/db/src/query/ir.ts:444](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L444) Sources declared by a FROM clause. UnionAll branches own their sources. diff --git a/docs/reference/@tanstack/namespaces/IR/functions/getHavingExpression.md b/docs/reference/@tanstack/namespaces/IR/functions/getHavingExpression.md index ad73a98147..63b75c1872 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/getHavingExpression.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/getHavingExpression.md @@ -9,7 +9,7 @@ function getHavingExpression(having): | Aggregate<any>; ``` -Defined in: [packages/db/src/query/ir.ts:407](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L407) +Defined in: [packages/db/src/query/ir.ts:415](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L415) Extract the expression from a HAVING clause HAVING clauses can contain aggregates, unlike regular WHERE clauses diff --git a/docs/reference/@tanstack/namespaces/IR/functions/getWhereExpression.md b/docs/reference/@tanstack/namespaces/IR/functions/getWhereExpression.md index 085fe9e367..f4bfbe4200 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/getWhereExpression.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/getWhereExpression.md @@ -7,7 +7,7 @@ title: getWhereExpression function getWhereExpression(where): BasicExpression<boolean>; ``` -Defined in: [packages/db/src/query/ir.ts:397](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L397) +Defined in: [packages/db/src/query/ir.ts:405](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L405) Extract the expression from a Where clause diff --git a/docs/reference/@tanstack/namespaces/IR/functions/isResidualWhere.md b/docs/reference/@tanstack/namespaces/IR/functions/isResidualWhere.md index c350afc596..4a6382c74e 100644 --- a/docs/reference/@tanstack/namespaces/IR/functions/isResidualWhere.md +++ b/docs/reference/@tanstack/namespaces/IR/functions/isResidualWhere.md @@ -7,7 +7,7 @@ title: isResidualWhere function isResidualWhere(where): boolean; ``` -Defined in: [packages/db/src/query/ir.ts:418](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L418) +Defined in: [packages/db/src/query/ir.ts:426](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir.ts#L426) Check if a Where clause is marked as residual diff --git a/docs/reference/classes/AggregateFunctionNotInSelectError.md b/docs/reference/classes/AggregateFunctionNotInSelectError.md index 54dbc5a00f..3297eaa318 100644 --- a/docs/reference/classes/AggregateFunctionNotInSelectError.md +++ b/docs/reference/classes/AggregateFunctionNotInSelectError.md @@ -3,7 +3,7 @@ id: AggregateFunctionNotInSelectError title: AggregateFunctionNotInSelectError --- -Defined in: [packages/db/src/errors.ts:967](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L967) +Defined in: [packages/db/src/errors.ts:997](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L997) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:967](https://github.com/TanStack/db/blob/ new AggregateFunctionNotInSelectError(functionName): AggregateFunctionNotInSelectError; ``` -Defined in: [packages/db/src/errors.ts:968](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L968) +Defined in: [packages/db/src/errors.ts:998](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L998) #### Parameters diff --git a/docs/reference/classes/CannotCombineEmptyExpressionListError.md b/docs/reference/classes/CannotCombineEmptyExpressionListError.md index 1507d2eac6..651e7d1a02 100644 --- a/docs/reference/classes/CannotCombineEmptyExpressionListError.md +++ b/docs/reference/classes/CannotCombineEmptyExpressionListError.md @@ -3,7 +3,7 @@ id: CannotCombineEmptyExpressionListError title: CannotCombineEmptyExpressionListError --- -Defined in: [packages/db/src/errors.ts:1099](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1099) +Defined in: [packages/db/src/errors.ts:1129](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1129) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:1099](https://github.com/TanStack/db/blob new CannotCombineEmptyExpressionListError(): CannotCombineEmptyExpressionListError; ``` -Defined in: [packages/db/src/errors.ts:1100](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1100) +Defined in: [packages/db/src/errors.ts:1130](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1130) #### Returns diff --git a/docs/reference/classes/CollectionHasSubscribersError.md b/docs/reference/classes/CollectionHasSubscribersError.md new file mode 100644 index 0000000000..972829f566 --- /dev/null +++ b/docs/reference/classes/CollectionHasSubscribersError.md @@ -0,0 +1,222 @@ +--- +id: CollectionHasSubscribersError +title: CollectionHasSubscribersError +--- + +Defined in: [packages/db/src/errors.ts:152](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L152) + +## Extends + +- [`CollectionStateError`](CollectionStateError.md) + +## Constructors + +### Constructor + +```ts +new CollectionHasSubscribersError(collectionId, dependents): CollectionHasSubscribersError; +``` + +Defined in: [packages/db/src/errors.ts:153](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L153) + +#### Parameters + +##### collectionId + +`string` + +##### dependents + +`number` + +#### Returns + +`CollectionHasSubscribersError` + +#### Overrides + +[`CollectionStateError`](CollectionStateError.md).[`constructor`](CollectionStateError.md#constructor) + +## Properties + +### cause? + +```ts +optional cause: unknown; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es2022.error.d.ts:26 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`cause`](CollectionStateError.md#cause) + +*** + +### message + +```ts +message: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`message`](CollectionStateError.md#message) + +*** + +### name + +```ts +name: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1076 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`name`](CollectionStateError.md#name) + +*** + +### stack? + +```ts +optional stack: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1078 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`stack`](CollectionStateError.md#stack) + +*** + +### stackTraceLimit + +```ts +static stackTraceLimit: number; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:67 + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`stackTraceLimit`](CollectionStateError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +```ts +static captureStackTrace(targetObject, constructorOpt?): void; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:51 + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`captureStackTrace`](CollectionStateError.md#capturestacktrace) + +*** + +### prepareStackTrace() + +```ts +static prepareStackTrace(err, stackTraces): any; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:55 + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`prepareStackTrace`](CollectionStateError.md#preparestacktrace) diff --git a/docs/reference/classes/CollectionImpl.md b/docs/reference/classes/CollectionImpl.md index 0203f77893..40fa8015f2 100644 --- a/docs/reference/classes/CollectionImpl.md +++ b/docs/reference/classes/CollectionImpl.md @@ -3,7 +3,7 @@ id: CollectionImpl title: CollectionImpl --- -Defined in: [packages/db/src/collection/index.ts:394](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L394) +Defined in: [packages/db/src/collection/index.ts:400](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L400) ## Extended by @@ -40,7 +40,7 @@ Defined in: [packages/db/src/collection/index.ts:394](https://github.com/TanStac new CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput>(config): CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput>; ``` -Defined in: [packages/db/src/collection/index.ts:440](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L440) +Defined in: [packages/db/src/collection/index.ts:446](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L446) Creates a new Collection instance @@ -68,7 +68,7 @@ Error if sync config is missing _lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput>; ``` -Defined in: [packages/db/src/collection/index.ts:411](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L411) +Defined in: [packages/db/src/collection/index.ts:417](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L417) *** @@ -78,7 +78,7 @@ Defined in: [packages/db/src/collection/index.ts:411](https://github.com/TanStac _state: CollectionStateManager<TOutput, TKey, TSchema, TInput>; ``` -Defined in: [packages/db/src/collection/index.ts:423](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L423) +Defined in: [packages/db/src/collection/index.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L429) *** @@ -88,7 +88,7 @@ Defined in: [packages/db/src/collection/index.ts:423](https://github.com/TanStac _sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>; ``` -Defined in: [packages/db/src/collection/index.ts:412](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L412) +Defined in: [packages/db/src/collection/index.ts:418](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L418) *** @@ -98,7 +98,7 @@ Defined in: [packages/db/src/collection/index.ts:412](https://github.com/TanStac config: CollectionConfig<TOutput, TKey, TSchema, TUtils>; ``` -Defined in: [packages/db/src/collection/index.ts:402](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L402) +Defined in: [packages/db/src/collection/index.ts:408](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L408) *** @@ -108,7 +108,7 @@ Defined in: [packages/db/src/collection/index.ts:402](https://github.com/TanStac deferDataRefresh: Promise<void> | null = null; ``` -Defined in: [packages/db/src/collection/index.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L430) +Defined in: [packages/db/src/collection/index.ts:436](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L436) When set, collection consumers should defer processing incoming data refreshes until this promise resolves. This prevents stale data from @@ -122,7 +122,7 @@ overwriting optimistic state while pending writes are being applied. id: string; ``` -Defined in: [packages/db/src/collection/index.ts:401](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L401) +Defined in: [packages/db/src/collection/index.ts:407](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L407) *** @@ -132,7 +132,7 @@ Defined in: [packages/db/src/collection/index.ts:401](https://github.com/TanStac utils: TUtils; ``` -Defined in: [packages/db/src/collection/index.ts:406](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L406) +Defined in: [packages/db/src/collection/index.ts:412](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L412) ## Accessors @@ -144,7 +144,7 @@ Defined in: [packages/db/src/collection/index.ts:406](https://github.com/TanStac get _layoutRevision(): number; ``` -Defined in: [packages/db/src/collection/index.ts:578](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L578) +Defined in: [packages/db/src/collection/index.ts:599](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L599) Monotonic revision of explicit layout-only publications. Internal — used to distinguish them from empty ready events. @@ -163,7 +163,7 @@ Internal — used to distinguish them from empty ready events. get _stateRevision(): number; ``` -Defined in: [packages/db/src/collection/index.ts:570](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L570) +Defined in: [packages/db/src/collection/index.ts:591](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L591) Monotonic revision of the collection's visible state; advances once per committed batch of changes and cleanup, even while nothing is subscribed. @@ -183,7 +183,7 @@ Internal — used by the live-query observer's snapshot cache. get base(): CollectionBase<TKey, TOutput>; ``` -Defined in: [packages/db/src/collection/index.ts:718](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L718) +Defined in: [packages/db/src/collection/index.ts:739](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L739) The exposed authoritative base rows, before optimistic writes are applied. Queued sync writes are excluded. Reading does not start sync. @@ -203,7 +203,7 @@ Do not mutate the backing map or its rows: that bypasses publication. get compareOptions(): StringCollationConfig; ``` -Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L918) +Defined in: [packages/db/src/collection/index.ts:939](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L939) ##### Returns @@ -211,6 +211,27 @@ Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStac *** +### health + +#### Get Signature + +```ts +get health(): HealthNode; +``` + +Defined in: [packages/db/src/collection/index.ts:569](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L569) + +The health tree for this Collection and every Collection it depends on. +A base Collection's tree is one node. It always has the same shape for a +given query, so apps can parse it. After the first ready, sync problems +appear here and the rows stay. + +##### Returns + +[`HealthNode`](../interfaces/HealthNode.md) + +*** + ### indexes #### Get Signature @@ -219,7 +240,7 @@ Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStac get indexes(): Map<number, BaseIndex<TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:903](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L903) +Defined in: [packages/db/src/collection/index.ts:924](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L924) Get resolved indexes for query optimization @@ -237,7 +258,7 @@ Get resolved indexes for query optimization get isLoadingSubset(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:636](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L636) +Defined in: [packages/db/src/collection/index.ts:657](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L657) Check if the collection is currently loading more data @@ -257,7 +278,7 @@ true if the collection has pending load more operations, false otherwise get size(): number; ``` -Defined in: [packages/db/src/collection/index.ts:768](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L768) +Defined in: [packages/db/src/collection/index.ts:789](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L789) Get the current size of the collection (cached) @@ -275,7 +296,7 @@ Get the current size of the collection (cached) get state(): Map<TKey, WithVirtualProps<TOutput, TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:1095](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1095) +Defined in: [packages/db/src/collection/index.ts:1116](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1116) Gets the current state of the collection as a Map @@ -311,7 +332,7 @@ Map containing all items in the collection, with keys as identifiers get status(): CollectionStatus; ``` -Defined in: [packages/db/src/collection/index.ts:553](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L553) +Defined in: [packages/db/src/collection/index.ts:559](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L559) Gets the current status of the collection @@ -329,7 +350,7 @@ Gets the current status of the collection get subscriberCount(): number; ``` -Defined in: [packages/db/src/collection/index.ts:561](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L561) +Defined in: [packages/db/src/collection/index.ts:582](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L582) Get the number of subscribers that ask for this collection's data. A live query that has no subscriber or preload of its own does not count. @@ -340,6 +361,25 @@ query that has no subscriber or preload of its own does not count. *** +### subscribers + +#### Get Signature + +```ts +get subscribers(): readonly CollectionDependent[]; +``` + +Defined in: [packages/db/src/collection/index.ts:1322](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1322) + +Live queries, Effects, and direct subscriptions that read this +Collection. `cleanup()` refuses while a live query or Effect reads it. + +##### Returns + +readonly [`CollectionDependent`](../interfaces/CollectionDependent.md)[] + +*** + ### toArray #### Get Signature @@ -348,7 +388,7 @@ query that has no subscriber or preload of its own does not count. get toArray(): WithVirtualProps<TOutput, TKey>[]; ``` -Defined in: [packages/db/src/collection/index.ts:1119](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1119) +Defined in: [packages/db/src/collection/index.ts:1140](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1140) Gets the current state of the collection as an Array @@ -366,7 +406,7 @@ An Array containing all items in the collection _deferPublication(): PublicationDeferral; ``` -Defined in: [packages/db/src/collection/index.ts:593](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L593) +Defined in: [packages/db/src/collection/index.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L614) Defer subscriber events until a coherent multi-Collection commit ends. @@ -382,7 +422,7 @@ Defer subscriber events until a coherent multi-Collection commit ends. _deferSyncStart(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:665](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L665) +Defined in: [packages/db/src/collection/index.ts:686](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L686) **`Internal`** @@ -392,13 +432,31 @@ Defined in: [packages/db/src/collection/index.ts:665](https://github.com/TanStac *** +### \_emitHealthChange() + +```ts +_emitHealthChange(): void; +``` + +Defined in: [packages/db/src/collection/index.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L574) + +**`Internal`** + +A Collection this one depends on changed its health tree. + +#### Returns + +`void` + +*** + ### \_hasHeldSyncTransaction() ```ts _hasHeldSyncTransaction(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L761) +Defined in: [packages/db/src/collection/index.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L782) Whether an accepted sync transaction is not yet visible. @@ -414,7 +472,7 @@ Whether an accepted sync transaction is not yet visible. _hasHydratedKey(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:660](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L660) +Defined in: [packages/db/src/collection/index.ts:681](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L681) **`Internal`** @@ -436,7 +494,7 @@ Defined in: [packages/db/src/collection/index.ts:660](https://github.com/TanStac _hasOptimisticOverlay(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:753](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L753) +Defined in: [packages/db/src/collection/index.ts:774](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L774) Whether a local write obscures this key's authoritative source row. @@ -458,7 +516,7 @@ Whether a local write obscures this key's authoritative source row. _hasSubscriberOrPreload(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:704](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L704) +Defined in: [packages/db/src/collection/index.ts:725](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L725) **`Internal`** @@ -476,7 +534,7 @@ Whether this Collection had a subscriber or a preload in this sync run. _markLayoutChange(): void; ``` -Defined in: [packages/db/src/collection/index.ts:588](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L588) +Defined in: [packages/db/src/collection/index.ts:609](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L609) Mark the active sync transaction as layout-changing. Internal. @@ -492,7 +550,7 @@ Mark the active sync transaction as layout-changing. Internal. _markPreload(): void; ``` -Defined in: [packages/db/src/collection/index.ts:699](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L699) +Defined in: [packages/db/src/collection/index.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L720) **`Internal`** @@ -512,7 +570,7 @@ query. The request still counts: deferred acquisition may now resume. _onCleanupStart(callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:650](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L650) +Defined in: [packages/db/src/collection/index.ts:671](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L671) **`Internal`** @@ -542,7 +600,7 @@ Subscribe to the synchronous cleanup-start boundary. _onFirstSubscriberOrPreload(listener): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:709](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L709) +Defined in: [packages/db/src/collection/index.ts:730](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L730) **`Internal`** @@ -572,7 +630,7 @@ Listen for the first subscriber or preload in this sync run. _optimisticOverlaySize(): number; ``` -Defined in: [packages/db/src/collection/index.ts:746](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L746) +Defined in: [packages/db/src/collection/index.ts:767](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L767) Maximum number of source rows whose visible projection a local write can hide. @@ -588,7 +646,7 @@ Maximum number of source rows whose visible projection a local write can hide. _resumeSyncStart(): void; ``` -Defined in: [packages/db/src/collection/index.ts:670](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L670) +Defined in: [packages/db/src/collection/index.ts:691](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L691) **`Internal`** @@ -604,7 +662,7 @@ Defined in: [packages/db/src/collection/index.ts:670](https://github.com/TanStac _setTransactionScope(transactionScope): void; ``` -Defined in: [packages/db/src/collection/index.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L655) +Defined in: [packages/db/src/collection/index.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L676) **`Internal`** @@ -626,7 +684,7 @@ Defined in: [packages/db/src/collection/index.ts:655](https://github.com/TanStac _subscribeLayoutChanges(listener): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L583) +Defined in: [packages/db/src/collection/index.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L604) Subscribe to layout-only publications. Internal observer channel. @@ -654,7 +712,7 @@ Subscribe to layout-only publications. Internal observer channel. _syncedRowMetadataKeysOutsideBase(): TKey[]; ``` -Defined in: [packages/db/src/collection/index.ts:723](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L723) +Defined in: [packages/db/src/collection/index.ts:744](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L744) **`Internal`** @@ -672,7 +730,7 @@ Applied metadata keys without a source row. iterator: IterableIterator<[TKey, WithVirtualProps<TOutput, TKey>]>; ``` -Defined in: [packages/db/src/collection/index.ts:806](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L806) +Defined in: [packages/db/src/collection/index.ts:827](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L827) Get all entries (virtual derived state) @@ -685,10 +743,10 @@ Get all entries (virtual derived state) ### cleanup() ```ts -cleanup(): Promise<void>; +cleanup(options?): Promise<void>; ``` -Defined in: [packages/db/src/collection/index.ts:1286](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1286) +Defined in: [packages/db/src/collection/index.ts:1356](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1356) Clean up the collection by stopping sync and clearing data This can be called manually or automatically by garbage collection @@ -697,6 +755,22 @@ Wait until cleanup completes before starting a new sync run. If adapter cleanup rejects, this promise rejects after the Collection reaches its final cleaned-up state. +Rejects with `CollectionHasSubscribersError` while an active live query +(one that still has a subscriber) or an Effect reads this Collection, +because its readers would lose their data. A live query nothing reads +any more ends with this Collection's cleanup start. With +`{ dependents: true }`, it first cleans up each live query (and its own +dependents) and disposes each Effect. A direct subscription survives +cleanup and asks for data again when a new sync run starts. + +#### Parameters + +##### options? + +###### dependents? + +`boolean` + #### Returns `Promise`\<`void`\> @@ -709,7 +783,7 @@ final cleaned-up state. createIndex<TIndexType>(indexCallback, config): BaseIndex<TKey>; ``` -Defined in: [packages/db/src/collection/index.ts:872](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L872) +Defined in: [packages/db/src/collection/index.ts:893](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L893) Creates an index on a collection for faster queries. Indexes significantly improve query performance by allowing constant time lookups @@ -765,7 +839,7 @@ currentStateAsChanges(options): | ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey>[]; ``` -Defined in: [packages/db/src/collection/index.ts:1152](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1152) +Defined in: [packages/db/src/collection/index.ts:1173](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1173) Returns the current state of the collection as an array of changes @@ -809,7 +883,7 @@ const activeChanges = collection.currentStateAsChanges({ delete(keys, config?): Transaction<any>; ``` -Defined in: [packages/db/src/collection/index.ts:1072](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1072) +Defined in: [packages/db/src/collection/index.ts:1093](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1093) Deletes one or more items from the collection @@ -872,7 +946,7 @@ try { entries(): IterableIterator<[TKey, WithVirtualProps<TOutput, TKey>]>; ``` -Defined in: [packages/db/src/collection/index.ts:794](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L794) +Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) Get all entries (virtual derived state) @@ -888,7 +962,7 @@ Get all entries (virtual derived state) forEach(callbackfn): void; ``` -Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) +Defined in: [packages/db/src/collection/index.ts:836](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L836) Execute a callback for each entry in the collection @@ -912,7 +986,7 @@ get(key): | undefined; ``` -Defined in: [packages/db/src/collection/index.ts:734](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L734) +Defined in: [packages/db/src/collection/index.ts:755](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L755) Get the current value for a key (virtual derived state) @@ -935,7 +1009,7 @@ Get the current value for a key (virtual derived state) getIndexMetadata(): CollectionIndexMetadata[]; ``` -Defined in: [packages/db/src/collection/index.ts:896](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L896) +Defined in: [packages/db/src/collection/index.ts:917](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L917) Returns a snapshot of current index metadata sorted by indexId. Persistence wrappers can use this to bootstrap index state if indexes were @@ -953,7 +1027,7 @@ created before event listeners were attached. getKeyFromItem(item): TKey; ``` -Defined in: [packages/db/src/collection/index.ts:846](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L846) +Defined in: [packages/db/src/collection/index.ts:867](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L867) #### Parameters @@ -973,7 +1047,7 @@ Defined in: [packages/db/src/collection/index.ts:846](https://github.com/TanStac has(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:741](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L741) +Defined in: [packages/db/src/collection/index.ts:762](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L762) Check if a key exists in the collection (virtual derived state) @@ -997,7 +1071,7 @@ insert(data, config?): | Transaction<TOutput>; ``` -Defined in: [packages/db/src/collection/index.ts:959](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L959) +Defined in: [packages/db/src/collection/index.ts:980](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L980) Inserts one or more items into the collection @@ -1068,7 +1142,7 @@ try { isReady(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:628](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L628) +Defined in: [packages/db/src/collection/index.ts:649](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L649) Check if the collection is ready for use Returns true if the collection has been marked as ready by its sync implementation @@ -1098,7 +1172,7 @@ if (collection.isReady()) { keys(): IterableIterator<TKey>; ``` -Defined in: [packages/db/src/collection/index.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L775) +Defined in: [packages/db/src/collection/index.ts:796](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L796) Get all keys (virtual derived state) @@ -1114,7 +1188,7 @@ Get all keys (virtual derived state) map<U>(callbackfn): U[]; ``` -Defined in: [packages/db/src/collection/index.ts:831](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L831) +Defined in: [packages/db/src/collection/index.ts:852](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L852) Create a new array with the results of calling a function for each entry in the collection @@ -1142,7 +1216,7 @@ Create a new array with the results of calling a function for each entry in the off<T>(event, callback): void; ``` -Defined in: [packages/db/src/collection/index.ts:1261](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1261) +Defined in: [packages/db/src/collection/index.ts:1282](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1282) Unsubscribe from a collection event @@ -1155,10 +1229,12 @@ Unsubscribe from a collection event \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` @@ -1186,7 +1262,7 @@ Unsubscribe from a collection event on<T>(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:1241](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1241) +Defined in: [packages/db/src/collection/index.ts:1262](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1262) Subscribe to a collection event @@ -1199,10 +1275,12 @@ Subscribe to a collection event \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` @@ -1236,7 +1314,7 @@ Subscribe to a collection event once<T>(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:1251](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1251) +Defined in: [packages/db/src/collection/index.ts:1272](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1272) Subscribe to a collection event once @@ -1249,10 +1327,12 @@ Subscribe to a collection event once \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` @@ -1286,7 +1366,7 @@ Subscribe to a collection event once onFirstReady(callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:612](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L612) +Defined in: [packages/db/src/collection/index.ts:633](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L633) Register a callback to be executed when the collection first becomes ready Useful for preloading collections @@ -1331,7 +1411,7 @@ collection.onFirstReady(() => { preload(): Promise<void>; ``` -Defined in: [packages/db/src/collection/index.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L678) +Defined in: [packages/db/src/collection/index.ts:699](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L699) Preload the collection data by starting sync if not already started Multiple concurrent calls will share the same promise @@ -1342,13 +1422,40 @@ Multiple concurrent calls will share the same promise *** +### reload() + +```ts +reload(): Promise<void>; +``` + +Defined in: [packages/db/src/collection/index.ts:1313](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1313) + +Start a new sync run while everything visible stays: the rows, the +status, and the current error, with `reloading` on this Collection's +health node. The new run's first commit replaces the rows at once and +clears the node's problem; a `status` of `error` becomes `ready`. If the +new run fails first, the rows and status stay and the node shows the new +error. On a live query, it reloads each of its sources that has an +`error`, and then the query itself if its own node has an `error`: a +failed first load or a failed request for more data. + +Returns the promise of a reload in flight when called again. Rejects on a +`crashed` Collection: a crash is not recovered in place. Clean the +Collection up with its dependents to start it fresh. + +#### Returns + +`Promise`\<`void`\> + +*** + ### removeIndex() ```ts removeIndex(indexOrId): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:887](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L887) +Defined in: [packages/db/src/collection/index.ts:908](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L908) Removes an index created with createIndex. Returns true when an index existed and was removed. @@ -1375,7 +1482,7 @@ as invalid after removal. startSyncImmediate(): void; ``` -Defined in: [packages/db/src/collection/index.ts:645](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L645) +Defined in: [packages/db/src/collection/index.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L666) Start sync immediately - internal method for compiled queries This bypasses lazy loading for special cases like live query results @@ -1393,7 +1500,7 @@ Throws during active cleanup; restart after cleanup completes instead. stateWhenReady(): Promise<Map<TKey, WithVirtualProps<TOutput, TKey>>>; ``` -Defined in: [packages/db/src/collection/index.ts:1110](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1110) +Defined in: [packages/db/src/collection/index.ts:1131](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1131) Gets the current state of the collection as a Map once the collection is ready Resolves when `preload()` does. For a live query, that is initial-query @@ -1415,7 +1522,7 @@ Promise that resolves to a Map containing all items in the collection subscribeChanges(callback, options?): CollectionSubscription; ``` -Defined in: [packages/db/src/collection/index.ts:1204](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1204) +Defined in: [packages/db/src/collection/index.ts:1225](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1225) Subscribe to changes in the collection Changes to the same key retain their causal order within a callback. @@ -1490,7 +1597,7 @@ const subscription = collection.subscribeChanges((changes) => { subscribeChanges(callback, options?): CollectionSubscription; ``` -Defined in: [packages/db/src/collection/index.ts:1212](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1212) +Defined in: [packages/db/src/collection/index.ts:1233](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1233) Subscribe to changes in the collection Changes to the same key retain their causal order within a callback. @@ -1567,7 +1674,7 @@ const subscription = collection.subscribeChanges((changes) => { toArrayWhenReady(): Promise<WithVirtualProps<TOutput, TKey>[]>; ``` -Defined in: [packages/db/src/collection/index.ts:1130](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1130) +Defined in: [packages/db/src/collection/index.ts:1151](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1151) Gets the current state of the collection as an Array once the collection is ready Resolves when `preload()` does. For a live query, that is initial-query @@ -1589,7 +1696,7 @@ Promise that resolves to an Array containing all items in the collection update(key, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1004](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1004) +Defined in: [packages/db/src/collection/index.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1025) Updates one or more items in the collection using a callback function @@ -1660,7 +1767,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1010](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1010) +Defined in: [packages/db/src/collection/index.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1031) Updates one or more items in the collection using a callback function @@ -1734,7 +1841,7 @@ try { update(id, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1017](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1017) +Defined in: [packages/db/src/collection/index.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1038) Updates one or more items in the collection using a callback function @@ -1805,7 +1912,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1023](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1023) +Defined in: [packages/db/src/collection/index.ts:1044](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1044) Updates one or more items in the collection using a callback function @@ -1882,7 +1989,7 @@ validateData( key?): TOutput; ``` -Defined in: [packages/db/src/collection/index.ts:910](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L910) +Defined in: [packages/db/src/collection/index.ts:931](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L931) Validates the data against the schema @@ -1912,7 +2019,7 @@ Validates the data against the schema values(): IterableIterator<WithVirtualProps<TOutput, TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L782) +Defined in: [packages/db/src/collection/index.ts:803](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L803) Get all values (virtual derived state) @@ -1928,7 +2035,7 @@ Get all values (virtual derived state) waitFor<T>(event, timeout?): Promise<AllCollectionEvents[T]>; ``` -Defined in: [packages/db/src/collection/index.ts:1271](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1271) +Defined in: [packages/db/src/collection/index.ts:1292](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1292) Wait for a collection event @@ -1941,10 +2048,12 @@ Wait for a collection event \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` diff --git a/docs/reference/classes/CollectionInputNotFoundError.md b/docs/reference/classes/CollectionInputNotFoundError.md index a680a77cbf..d5517b4dd4 100644 --- a/docs/reference/classes/CollectionInputNotFoundError.md +++ b/docs/reference/classes/CollectionInputNotFoundError.md @@ -3,7 +3,7 @@ id: CollectionInputNotFoundError title: CollectionInputNotFoundError --- -Defined in: [packages/db/src/errors.ts:766](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L766) +Defined in: [packages/db/src/errors.ts:796](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L796) Error thrown when a collection input stream is not found during query compilation. In self-joins, each alias (e.g., 'employee', 'manager') requires its own input stream. @@ -23,7 +23,7 @@ new CollectionInputNotFoundError( availableKeys?): CollectionInputNotFoundError; ``` -Defined in: [packages/db/src/errors.ts:767](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L767) +Defined in: [packages/db/src/errors.ts:797](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L797) #### Parameters diff --git a/docs/reference/classes/CollectionIsInErrorStateError.md b/docs/reference/classes/CollectionIsInErrorStateError.md index e1ff5bc89e..a744de3c30 100644 --- a/docs/reference/classes/CollectionIsInErrorStateError.md +++ b/docs/reference/classes/CollectionIsInErrorStateError.md @@ -3,7 +3,7 @@ id: CollectionIsInErrorStateError title: CollectionIsInErrorStateError --- -Defined in: [packages/db/src/errors.ts:162](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L162) +Defined in: [packages/db/src/errors.ts:192](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L192) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:162](https://github.com/TanStack/db/blob/ new CollectionIsInErrorStateError(): CollectionIsInErrorStateError; ``` -Defined in: [packages/db/src/errors.ts:163](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L163) +Defined in: [packages/db/src/errors.ts:193](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L193) #### Returns diff --git a/docs/reference/classes/CollectionOperationError.md b/docs/reference/classes/CollectionOperationError.md index ca6ad8b079..560e6ab038 100644 --- a/docs/reference/classes/CollectionOperationError.md +++ b/docs/reference/classes/CollectionOperationError.md @@ -3,7 +3,7 @@ id: CollectionOperationError title: CollectionOperationError --- -Defined in: [packages/db/src/errors.ts:203](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L203) +Defined in: [packages/db/src/errors.ts:233](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L233) ## Extends @@ -30,7 +30,7 @@ Defined in: [packages/db/src/errors.ts:203](https://github.com/TanStack/db/blob/ new CollectionOperationError(message): CollectionOperationError; ``` -Defined in: [packages/db/src/errors.ts:204](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L204) +Defined in: [packages/db/src/errors.ts:234](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L234) #### Parameters diff --git a/docs/reference/classes/CollectionPreloadAbortedError.md b/docs/reference/classes/CollectionPreloadAbortedError.md index 5578be5873..febba48f26 100644 --- a/docs/reference/classes/CollectionPreloadAbortedError.md +++ b/docs/reference/classes/CollectionPreloadAbortedError.md @@ -3,7 +3,7 @@ id: CollectionPreloadAbortedError title: CollectionPreloadAbortedError --- -Defined in: [packages/db/src/errors.ts:1068](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1068) +Defined in: [packages/db/src/errors.ts:1098](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1098) A collection was cleaned up before its initial preload became ready. @@ -19,7 +19,7 @@ A collection was cleaned up before its initial preload became ready. new CollectionPreloadAbortedError(): CollectionPreloadAbortedError; ``` -Defined in: [packages/db/src/errors.ts:1069](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1069) +Defined in: [packages/db/src/errors.ts:1099](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1099) #### Returns diff --git a/docs/reference/classes/CollectionReloadCrashedError.md b/docs/reference/classes/CollectionReloadCrashedError.md new file mode 100644 index 0000000000..b50636dcc4 --- /dev/null +++ b/docs/reference/classes/CollectionReloadCrashedError.md @@ -0,0 +1,218 @@ +--- +id: CollectionReloadCrashedError +title: CollectionReloadCrashedError +--- + +Defined in: [packages/db/src/errors.ts:162](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L162) + +## Extends + +- [`CollectionStateError`](CollectionStateError.md) + +## Constructors + +### Constructor + +```ts +new CollectionReloadCrashedError(collectionId): CollectionReloadCrashedError; +``` + +Defined in: [packages/db/src/errors.ts:163](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L163) + +#### Parameters + +##### collectionId + +`string` + +#### Returns + +`CollectionReloadCrashedError` + +#### Overrides + +[`CollectionStateError`](CollectionStateError.md).[`constructor`](CollectionStateError.md#constructor) + +## Properties + +### cause? + +```ts +optional cause: unknown; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es2022.error.d.ts:26 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`cause`](CollectionStateError.md#cause) + +*** + +### message + +```ts +message: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`message`](CollectionStateError.md#message) + +*** + +### name + +```ts +name: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1076 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`name`](CollectionStateError.md#name) + +*** + +### stack? + +```ts +optional stack: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1078 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`stack`](CollectionStateError.md#stack) + +*** + +### stackTraceLimit + +```ts +static stackTraceLimit: number; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:67 + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`stackTraceLimit`](CollectionStateError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +```ts +static captureStackTrace(targetObject, constructorOpt?): void; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:51 + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`captureStackTrace`](CollectionStateError.md#capturestacktrace) + +*** + +### prepareStackTrace() + +```ts +static prepareStackTrace(err, stackTraces): any; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:55 + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`prepareStackTrace`](CollectionStateError.md#preparestacktrace) diff --git a/docs/reference/classes/CollectionReloadDuringSyncEntryError.md b/docs/reference/classes/CollectionReloadDuringSyncEntryError.md new file mode 100644 index 0000000000..fc12ffa728 --- /dev/null +++ b/docs/reference/classes/CollectionReloadDuringSyncEntryError.md @@ -0,0 +1,218 @@ +--- +id: CollectionReloadDuringSyncEntryError +title: CollectionReloadDuringSyncEntryError +--- + +Defined in: [packages/db/src/errors.ts:172](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L172) + +## Extends + +- [`CollectionStateError`](CollectionStateError.md) + +## Constructors + +### Constructor + +```ts +new CollectionReloadDuringSyncEntryError(collectionId): CollectionReloadDuringSyncEntryError; +``` + +Defined in: [packages/db/src/errors.ts:173](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L173) + +#### Parameters + +##### collectionId + +`string` + +#### Returns + +`CollectionReloadDuringSyncEntryError` + +#### Overrides + +[`CollectionStateError`](CollectionStateError.md).[`constructor`](CollectionStateError.md#constructor) + +## Properties + +### cause? + +```ts +optional cause: unknown; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es2022.error.d.ts:26 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`cause`](CollectionStateError.md#cause) + +*** + +### message + +```ts +message: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`message`](CollectionStateError.md#message) + +*** + +### name + +```ts +name: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1076 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`name`](CollectionStateError.md#name) + +*** + +### stack? + +```ts +optional stack: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1078 + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`stack`](CollectionStateError.md#stack) + +*** + +### stackTraceLimit + +```ts +static stackTraceLimit: number; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:67 + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`stackTraceLimit`](CollectionStateError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +```ts +static captureStackTrace(targetObject, constructorOpt?): void; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:51 + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`captureStackTrace`](CollectionStateError.md#capturestacktrace) + +*** + +### prepareStackTrace() + +```ts +static prepareStackTrace(err, stackTraces): any; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:55 + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`CollectionStateError`](CollectionStateError.md).[`prepareStackTrace`](CollectionStateError.md#preparestacktrace) diff --git a/docs/reference/classes/CollectionStateError.md b/docs/reference/classes/CollectionStateError.md index 798aaa4147..27d50dee6a 100644 --- a/docs/reference/classes/CollectionStateError.md +++ b/docs/reference/classes/CollectionStateError.md @@ -12,6 +12,9 @@ Defined in: [packages/db/src/errors.ts:135](https://github.com/TanStack/db/blob/ ## Extended by - [`CollectionInErrorStateError`](CollectionInErrorStateError.md) +- [`CollectionHasSubscribersError`](CollectionHasSubscribersError.md) +- [`CollectionReloadCrashedError`](CollectionReloadCrashedError.md) +- [`CollectionReloadDuringSyncEntryError`](CollectionReloadDuringSyncEntryError.md) - [`InvalidCollectionStatusTransitionError`](InvalidCollectionStatusTransitionError.md) - [`CollectionIsInErrorStateError`](CollectionIsInErrorStateError.md) - [`NegativeActiveSubscribersError`](NegativeActiveSubscribersError.md) diff --git a/docs/reference/classes/DebounceCallDroppedError.md b/docs/reference/classes/DebounceCallDroppedError.md index bfbb82b2b1..de9c53d156 100644 --- a/docs/reference/classes/DebounceCallDroppedError.md +++ b/docs/reference/classes/DebounceCallDroppedError.md @@ -3,7 +3,7 @@ id: DebounceCallDroppedError title: DebounceCallDroppedError --- -Defined in: [packages/db/src/errors.ts:436](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L436) +Defined in: [packages/db/src/errors.ts:466](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L466) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:436](https://github.com/TanStack/db/blob/ new DebounceCallDroppedError(): DebounceCallDroppedError; ``` -Defined in: [packages/db/src/errors.ts:437](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L437) +Defined in: [packages/db/src/errors.ts:467](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L467) #### Returns diff --git a/docs/reference/classes/DeleteKeyNotFoundError.md b/docs/reference/classes/DeleteKeyNotFoundError.md index 9c993d75e6..9de46d62cb 100644 --- a/docs/reference/classes/DeleteKeyNotFoundError.md +++ b/docs/reference/classes/DeleteKeyNotFoundError.md @@ -3,7 +3,7 @@ id: DeleteKeyNotFoundError title: DeleteKeyNotFoundError --- -Defined in: [packages/db/src/errors.ts:336](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L336) +Defined in: [packages/db/src/errors.ts:366](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L366) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:336](https://github.com/TanStack/db/blob/ new DeleteKeyNotFoundError(key): DeleteKeyNotFoundError; ``` -Defined in: [packages/db/src/errors.ts:337](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L337) +Defined in: [packages/db/src/errors.ts:367](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L367) #### Parameters diff --git a/docs/reference/classes/DistinctRequiresSelectError.md b/docs/reference/classes/DistinctRequiresSelectError.md index 42ef8ab241..56ccf10fa4 100644 --- a/docs/reference/classes/DistinctRequiresSelectError.md +++ b/docs/reference/classes/DistinctRequiresSelectError.md @@ -3,7 +3,7 @@ id: DistinctRequiresSelectError title: DistinctRequiresSelectError --- -Defined in: [packages/db/src/errors.ts:696](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L696) +Defined in: [packages/db/src/errors.ts:726](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L726) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:696](https://github.com/TanStack/db/blob/ new DistinctRequiresSelectError(): DistinctRequiresSelectError; ``` -Defined in: [packages/db/src/errors.ts:697](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L697) +Defined in: [packages/db/src/errors.ts:727](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L727) #### Returns diff --git a/docs/reference/classes/DuplicateAliasInSubqueryError.md b/docs/reference/classes/DuplicateAliasInSubqueryError.md index e9dd8e6dc2..577f6ad714 100644 --- a/docs/reference/classes/DuplicateAliasInSubqueryError.md +++ b/docs/reference/classes/DuplicateAliasInSubqueryError.md @@ -3,7 +3,7 @@ id: DuplicateAliasInSubqueryError title: DuplicateAliasInSubqueryError --- -Defined in: [packages/db/src/errors.ts:798](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L798) +Defined in: [packages/db/src/errors.ts:828](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L828) Retained for callers that imported this formerly thrown error. @@ -24,7 +24,7 @@ are rejected only within one lexical scope or union branch namespace. new DuplicateAliasInSubqueryError(alias, parentAliases): DuplicateAliasInSubqueryError; ``` -Defined in: [packages/db/src/errors.ts:799](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L799) +Defined in: [packages/db/src/errors.ts:829](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L829) #### Parameters diff --git a/docs/reference/classes/DuplicateKeyError.md b/docs/reference/classes/DuplicateKeyError.md index 8b4d7119ba..7857a86188 100644 --- a/docs/reference/classes/DuplicateKeyError.md +++ b/docs/reference/classes/DuplicateKeyError.md @@ -3,7 +3,7 @@ id: DuplicateKeyError title: DuplicateKeyError --- -Defined in: [packages/db/src/errors.ts:230](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L230) +Defined in: [packages/db/src/errors.ts:260](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L260) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:230](https://github.com/TanStack/db/blob/ new DuplicateKeyError(key): DuplicateKeyError; ``` -Defined in: [packages/db/src/errors.ts:231](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L231) +Defined in: [packages/db/src/errors.ts:261](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L261) #### Parameters diff --git a/docs/reference/classes/DuplicateKeySyncError.md b/docs/reference/classes/DuplicateKeySyncError.md index 7ac38859cd..60e2e77a14 100644 --- a/docs/reference/classes/DuplicateKeySyncError.md +++ b/docs/reference/classes/DuplicateKeySyncError.md @@ -3,7 +3,7 @@ id: DuplicateKeySyncError title: DuplicateKeySyncError --- -Defined in: [packages/db/src/errors.ts:240](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L240) +Defined in: [packages/db/src/errors.ts:270](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L270) ## Extends @@ -20,7 +20,7 @@ new DuplicateKeySyncError( options?): DuplicateKeySyncError; ``` -Defined in: [packages/db/src/errors.ts:241](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L241) +Defined in: [packages/db/src/errors.ts:271](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L271) #### Parameters diff --git a/docs/reference/classes/DuplicateTransactionIdError.md b/docs/reference/classes/DuplicateTransactionIdError.md index 5080aeb9e0..5df8a608c0 100644 --- a/docs/reference/classes/DuplicateTransactionIdError.md +++ b/docs/reference/classes/DuplicateTransactionIdError.md @@ -3,7 +3,7 @@ id: DuplicateTransactionIdError title: DuplicateTransactionIdError --- -Defined in: [packages/db/src/errors.ts:488](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L488) +Defined in: [packages/db/src/errors.ts:518](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L518) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:488](https://github.com/TanStack/db/blob/ new DuplicateTransactionIdError(id): DuplicateTransactionIdError; ``` -Defined in: [packages/db/src/errors.ts:489](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L489) +Defined in: [packages/db/src/errors.ts:519](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L519) #### Parameters diff --git a/docs/reference/classes/EmptyReferencePathError.md b/docs/reference/classes/EmptyReferencePathError.md index f6a0a9ba0c..525dfc92a5 100644 --- a/docs/reference/classes/EmptyReferencePathError.md +++ b/docs/reference/classes/EmptyReferencePathError.md @@ -3,7 +3,7 @@ id: EmptyReferencePathError title: EmptyReferencePathError --- -Defined in: [packages/db/src/errors.ts:831](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L831) +Defined in: [packages/db/src/errors.ts:861](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L861) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:831](https://github.com/TanStack/db/blob/ new EmptyReferencePathError(): EmptyReferencePathError; ``` -Defined in: [packages/db/src/errors.ts:832](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L832) +Defined in: [packages/db/src/errors.ts:862](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L862) #### Returns diff --git a/docs/reference/classes/FnSelectWithGroupByError.md b/docs/reference/classes/FnSelectWithGroupByError.md index f31d7e520c..540aa82bce 100644 --- a/docs/reference/classes/FnSelectWithGroupByError.md +++ b/docs/reference/classes/FnSelectWithGroupByError.md @@ -3,7 +3,7 @@ id: FnSelectWithGroupByError title: FnSelectWithGroupByError --- -Defined in: [packages/db/src/errors.ts:706](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L706) +Defined in: [packages/db/src/errors.ts:736](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L736) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:706](https://github.com/TanStack/db/blob/ new FnSelectWithGroupByError(): FnSelectWithGroupByError; ``` -Defined in: [packages/db/src/errors.ts:707](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L707) +Defined in: [packages/db/src/errors.ts:737](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L737) #### Returns diff --git a/docs/reference/classes/GroupByError.md b/docs/reference/classes/GroupByError.md index dda9264335..5c380151c4 100644 --- a/docs/reference/classes/GroupByError.md +++ b/docs/reference/classes/GroupByError.md @@ -3,7 +3,7 @@ id: GroupByError title: GroupByError --- -Defined in: [packages/db/src/errors.ts:940](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L940) +Defined in: [packages/db/src/errors.ts:970](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L970) ## Extends @@ -24,7 +24,7 @@ Defined in: [packages/db/src/errors.ts:940](https://github.com/TanStack/db/blob/ new GroupByError(message): GroupByError; ``` -Defined in: [packages/db/src/errors.ts:941](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L941) +Defined in: [packages/db/src/errors.ts:971](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L971) #### Parameters diff --git a/docs/reference/classes/HavingRequiresGroupByError.md b/docs/reference/classes/HavingRequiresGroupByError.md index e11ac460b9..76fc96eecc 100644 --- a/docs/reference/classes/HavingRequiresGroupByError.md +++ b/docs/reference/classes/HavingRequiresGroupByError.md @@ -3,7 +3,7 @@ id: HavingRequiresGroupByError title: HavingRequiresGroupByError --- -Defined in: [packages/db/src/errors.ts:742](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L742) +Defined in: [packages/db/src/errors.ts:772](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L772) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:742](https://github.com/TanStack/db/blob/ new HavingRequiresGroupByError(): HavingRequiresGroupByError; ``` -Defined in: [packages/db/src/errors.ts:743](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L743) +Defined in: [packages/db/src/errors.ts:773](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L773) #### Returns diff --git a/docs/reference/classes/InvalidCollectionStatusTransitionError.md b/docs/reference/classes/InvalidCollectionStatusTransitionError.md index 83005acf46..41c598394e 100644 --- a/docs/reference/classes/InvalidCollectionStatusTransitionError.md +++ b/docs/reference/classes/InvalidCollectionStatusTransitionError.md @@ -3,7 +3,7 @@ id: InvalidCollectionStatusTransitionError title: InvalidCollectionStatusTransitionError --- -Defined in: [packages/db/src/errors.ts:152](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L152) +Defined in: [packages/db/src/errors.ts:182](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L182) ## Extends @@ -20,7 +20,7 @@ new InvalidCollectionStatusTransitionError( collectionId): InvalidCollectionStatusTransitionError; ``` -Defined in: [packages/db/src/errors.ts:153](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L153) +Defined in: [packages/db/src/errors.ts:183](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L183) #### Parameters diff --git a/docs/reference/classes/InvalidJoinCondition.md b/docs/reference/classes/InvalidJoinCondition.md index b04f1d4d35..11e655ea21 100644 --- a/docs/reference/classes/InvalidJoinCondition.md +++ b/docs/reference/classes/InvalidJoinCondition.md @@ -3,7 +3,7 @@ id: InvalidJoinCondition title: InvalidJoinCondition --- -Defined in: [packages/db/src/errors.ts:919](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L919) +Defined in: [packages/db/src/errors.ts:949](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L949) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:919](https://github.com/TanStack/db/blob/ new InvalidJoinCondition(): InvalidJoinCondition; ``` -Defined in: [packages/db/src/errors.ts:920](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L920) +Defined in: [packages/db/src/errors.ts:950](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L950) #### Returns diff --git a/docs/reference/classes/InvalidJoinConditionLeftSourceError.md b/docs/reference/classes/InvalidJoinConditionLeftSourceError.md index 262f678750..2e737587e5 100644 --- a/docs/reference/classes/InvalidJoinConditionLeftSourceError.md +++ b/docs/reference/classes/InvalidJoinConditionLeftSourceError.md @@ -3,7 +3,7 @@ id: InvalidJoinConditionLeftSourceError title: InvalidJoinConditionLeftSourceError --- -Defined in: [packages/db/src/errors.ts:899](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L899) +Defined in: [packages/db/src/errors.ts:929](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L929) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:899](https://github.com/TanStack/db/blob/ new InvalidJoinConditionLeftSourceError(sourceAlias): InvalidJoinConditionLeftSourceError; ``` -Defined in: [packages/db/src/errors.ts:900](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L900) +Defined in: [packages/db/src/errors.ts:930](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L930) #### Parameters diff --git a/docs/reference/classes/InvalidJoinConditionRightSourceError.md b/docs/reference/classes/InvalidJoinConditionRightSourceError.md index 5e5006c01c..c8cd77219a 100644 --- a/docs/reference/classes/InvalidJoinConditionRightSourceError.md +++ b/docs/reference/classes/InvalidJoinConditionRightSourceError.md @@ -3,7 +3,7 @@ id: InvalidJoinConditionRightSourceError title: InvalidJoinConditionRightSourceError --- -Defined in: [packages/db/src/errors.ts:909](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L909) +Defined in: [packages/db/src/errors.ts:939](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L939) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:909](https://github.com/TanStack/db/blob/ new InvalidJoinConditionRightSourceError(sourceAlias): InvalidJoinConditionRightSourceError; ``` -Defined in: [packages/db/src/errors.ts:910](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L910) +Defined in: [packages/db/src/errors.ts:940](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L940) #### Parameters diff --git a/docs/reference/classes/InvalidJoinConditionSameSourceError.md b/docs/reference/classes/InvalidJoinConditionSameSourceError.md index 4c04251592..0cda862de5 100644 --- a/docs/reference/classes/InvalidJoinConditionSameSourceError.md +++ b/docs/reference/classes/InvalidJoinConditionSameSourceError.md @@ -3,7 +3,7 @@ id: InvalidJoinConditionSameSourceError title: InvalidJoinConditionSameSourceError --- -Defined in: [packages/db/src/errors.ts:879](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L879) +Defined in: [packages/db/src/errors.ts:909](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L909) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:879](https://github.com/TanStack/db/blob/ new InvalidJoinConditionSameSourceError(sourceAlias): InvalidJoinConditionSameSourceError; ``` -Defined in: [packages/db/src/errors.ts:880](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L880) +Defined in: [packages/db/src/errors.ts:910](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L910) #### Parameters diff --git a/docs/reference/classes/InvalidJoinConditionSourceMismatchError.md b/docs/reference/classes/InvalidJoinConditionSourceMismatchError.md index 0465d5a644..c3a0f8d030 100644 --- a/docs/reference/classes/InvalidJoinConditionSourceMismatchError.md +++ b/docs/reference/classes/InvalidJoinConditionSourceMismatchError.md @@ -3,7 +3,7 @@ id: InvalidJoinConditionSourceMismatchError title: InvalidJoinConditionSourceMismatchError --- -Defined in: [packages/db/src/errors.ts:889](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L889) +Defined in: [packages/db/src/errors.ts:919](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L919) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:889](https://github.com/TanStack/db/blob/ new InvalidJoinConditionSourceMismatchError(): InvalidJoinConditionSourceMismatchError; ``` -Defined in: [packages/db/src/errors.ts:890](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L890) +Defined in: [packages/db/src/errors.ts:920](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L920) #### Returns diff --git a/docs/reference/classes/InvalidKeyError.md b/docs/reference/classes/InvalidKeyError.md index 539b5e17ae..d139ea9b39 100644 --- a/docs/reference/classes/InvalidKeyError.md +++ b/docs/reference/classes/InvalidKeyError.md @@ -3,7 +3,7 @@ id: InvalidKeyError title: InvalidKeyError --- -Defined in: [packages/db/src/errors.ts:220](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L220) +Defined in: [packages/db/src/errors.ts:250](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L250) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:220](https://github.com/TanStack/db/blob/ new InvalidKeyError(key, item): InvalidKeyError; ``` -Defined in: [packages/db/src/errors.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L221) +Defined in: [packages/db/src/errors.ts:251](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L251) #### Parameters diff --git a/docs/reference/classes/InvalidSourceError.md b/docs/reference/classes/InvalidSourceError.md index 41fde3fd80..1384b71e5c 100644 --- a/docs/reference/classes/InvalidSourceError.md +++ b/docs/reference/classes/InvalidSourceError.md @@ -3,7 +3,7 @@ id: InvalidSourceError title: InvalidSourceError --- -Defined in: [packages/db/src/errors.ts:603](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L603) +Defined in: [packages/db/src/errors.ts:633](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L633) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:603](https://github.com/TanStack/db/blob/ new InvalidSourceError(alias): InvalidSourceError; ``` -Defined in: [packages/db/src/errors.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L604) +Defined in: [packages/db/src/errors.ts:634](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L634) #### Parameters diff --git a/docs/reference/classes/InvalidSourceTypeError.md b/docs/reference/classes/InvalidSourceTypeError.md index 6c672ddaaf..44db9beea6 100644 --- a/docs/reference/classes/InvalidSourceTypeError.md +++ b/docs/reference/classes/InvalidSourceTypeError.md @@ -3,7 +3,7 @@ id: InvalidSourceTypeError title: InvalidSourceTypeError --- -Defined in: [packages/db/src/errors.ts:616](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L616) +Defined in: [packages/db/src/errors.ts:646](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L646) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:616](https://github.com/TanStack/db/blob/ new InvalidSourceTypeError(context, type): InvalidSourceTypeError; ``` -Defined in: [packages/db/src/errors.ts:617](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L617) +Defined in: [packages/db/src/errors.ts:647](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L647) #### Parameters diff --git a/docs/reference/classes/InvalidStorageDataFormatError.md b/docs/reference/classes/InvalidStorageDataFormatError.md index 3c15655e8e..02cc96b418 100644 --- a/docs/reference/classes/InvalidStorageDataFormatError.md +++ b/docs/reference/classes/InvalidStorageDataFormatError.md @@ -3,7 +3,7 @@ id: InvalidStorageDataFormatError title: InvalidStorageDataFormatError --- -Defined in: [packages/db/src/errors.ts:1023](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1023) +Defined in: [packages/db/src/errors.ts:1053](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1053) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:1023](https://github.com/TanStack/db/blob new InvalidStorageDataFormatError(storageKey, key): InvalidStorageDataFormatError; ``` -Defined in: [packages/db/src/errors.ts:1024](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1024) +Defined in: [packages/db/src/errors.ts:1054](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1054) #### Parameters diff --git a/docs/reference/classes/InvalidStorageObjectFormatError.md b/docs/reference/classes/InvalidStorageObjectFormatError.md index adb1573d08..641af94173 100644 --- a/docs/reference/classes/InvalidStorageObjectFormatError.md +++ b/docs/reference/classes/InvalidStorageObjectFormatError.md @@ -3,7 +3,7 @@ id: InvalidStorageObjectFormatError title: InvalidStorageObjectFormatError --- -Defined in: [packages/db/src/errors.ts:1033](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1033) +Defined in: [packages/db/src/errors.ts:1063](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1063) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:1033](https://github.com/TanStack/db/blob new InvalidStorageObjectFormatError(storageKey): InvalidStorageObjectFormatError; ``` -Defined in: [packages/db/src/errors.ts:1034](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1034) +Defined in: [packages/db/src/errors.ts:1064](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1064) #### Parameters diff --git a/docs/reference/classes/InvalidWhereExpressionError.md b/docs/reference/classes/InvalidWhereExpressionError.md index 623a5eb4ac..9613e842ef 100644 --- a/docs/reference/classes/InvalidWhereExpressionError.md +++ b/docs/reference/classes/InvalidWhereExpressionError.md @@ -3,7 +3,7 @@ id: InvalidWhereExpressionError title: InvalidWhereExpressionError --- -Defined in: [packages/db/src/errors.ts:661](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L661) +Defined in: [packages/db/src/errors.ts:691](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L691) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:661](https://github.com/TanStack/db/blob/ new InvalidWhereExpressionError(valueType): InvalidWhereExpressionError; ``` -Defined in: [packages/db/src/errors.ts:662](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L662) +Defined in: [packages/db/src/errors.ts:692](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L692) #### Parameters diff --git a/docs/reference/classes/JoinCollectionNotFoundError.md b/docs/reference/classes/JoinCollectionNotFoundError.md index 413f597c7a..45f63eb577 100644 --- a/docs/reference/classes/JoinCollectionNotFoundError.md +++ b/docs/reference/classes/JoinCollectionNotFoundError.md @@ -3,7 +3,7 @@ id: JoinCollectionNotFoundError title: JoinCollectionNotFoundError --- -Defined in: [packages/db/src/errors.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L851) +Defined in: [packages/db/src/errors.ts:881](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L881) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:851](https://github.com/TanStack/db/blob/ new JoinCollectionNotFoundError(collectionId): JoinCollectionNotFoundError; ``` -Defined in: [packages/db/src/errors.ts:852](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L852) +Defined in: [packages/db/src/errors.ts:882](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L882) #### Parameters diff --git a/docs/reference/classes/JoinConditionMustBeEqualityError.md b/docs/reference/classes/JoinConditionMustBeEqualityError.md index a3dd24e4ce..9aca805f3c 100644 --- a/docs/reference/classes/JoinConditionMustBeEqualityError.md +++ b/docs/reference/classes/JoinConditionMustBeEqualityError.md @@ -3,7 +3,7 @@ id: JoinConditionMustBeEqualityError title: JoinConditionMustBeEqualityError --- -Defined in: [packages/db/src/errors.ts:641](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L641) +Defined in: [packages/db/src/errors.ts:671](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L671) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:641](https://github.com/TanStack/db/blob/ new JoinConditionMustBeEqualityError(): JoinConditionMustBeEqualityError; ``` -Defined in: [packages/db/src/errors.ts:642](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L642) +Defined in: [packages/db/src/errors.ts:672](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L672) #### Returns diff --git a/docs/reference/classes/JoinError.md b/docs/reference/classes/JoinError.md index 930a82c829..67a9ad1c47 100644 --- a/docs/reference/classes/JoinError.md +++ b/docs/reference/classes/JoinError.md @@ -3,7 +3,7 @@ id: JoinError title: JoinError --- -Defined in: [packages/db/src/errors.ts:862](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L862) +Defined in: [packages/db/src/errors.ts:892](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L892) ## Extends @@ -27,7 +27,7 @@ Defined in: [packages/db/src/errors.ts:862](https://github.com/TanStack/db/blob/ new JoinError(message): JoinError; ``` -Defined in: [packages/db/src/errors.ts:863](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L863) +Defined in: [packages/db/src/errors.ts:893](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L893) #### Parameters diff --git a/docs/reference/classes/KeyUpdateNotAllowedError.md b/docs/reference/classes/KeyUpdateNotAllowedError.md index 739806f7fd..e6c6024bd0 100644 --- a/docs/reference/classes/KeyUpdateNotAllowedError.md +++ b/docs/reference/classes/KeyUpdateNotAllowedError.md @@ -3,7 +3,7 @@ id: KeyUpdateNotAllowedError title: KeyUpdateNotAllowedError --- -Defined in: [packages/db/src/errors.ts:316](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L316) +Defined in: [packages/db/src/errors.ts:346](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L346) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:316](https://github.com/TanStack/db/blob/ new KeyUpdateNotAllowedError(originalKey, newKey): KeyUpdateNotAllowedError; ``` -Defined in: [packages/db/src/errors.ts:317](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L317) +Defined in: [packages/db/src/errors.ts:347](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L347) #### Parameters diff --git a/docs/reference/classes/LimitOffsetRequireOrderByError.md b/docs/reference/classes/LimitOffsetRequireOrderByError.md index 96678fc0e3..8c51ac8b7b 100644 --- a/docs/reference/classes/LimitOffsetRequireOrderByError.md +++ b/docs/reference/classes/LimitOffsetRequireOrderByError.md @@ -3,7 +3,7 @@ id: LimitOffsetRequireOrderByError title: LimitOffsetRequireOrderByError --- -Defined in: [packages/db/src/errors.ts:752](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L752) +Defined in: [packages/db/src/errors.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L782) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:752](https://github.com/TanStack/db/blob/ new LimitOffsetRequireOrderByError(): LimitOffsetRequireOrderByError; ``` -Defined in: [packages/db/src/errors.ts:753](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L753) +Defined in: [packages/db/src/errors.ts:783](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L783) #### Returns diff --git a/docs/reference/classes/LiveQueryObserverDisposedError.md b/docs/reference/classes/LiveQueryObserverDisposedError.md index 6218a6f0de..6ac19d4f9e 100644 --- a/docs/reference/classes/LiveQueryObserverDisposedError.md +++ b/docs/reference/classes/LiveQueryObserverDisposedError.md @@ -3,7 +3,7 @@ id: LiveQueryObserverDisposedError title: LiveQueryObserverDisposedError --- -Defined in: [packages/db/src/errors.ts:182](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L182) +Defined in: [packages/db/src/errors.ts:212](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L212) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:182](https://github.com/TanStack/db/blob/ new LiveQueryObserverDisposedError(): LiveQueryObserverDisposedError; ``` -Defined in: [packages/db/src/errors.ts:183](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L183) +Defined in: [packages/db/src/errors.ts:213](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L213) #### Returns diff --git a/docs/reference/classes/LiveQueryWindowControllerDisposedError.md b/docs/reference/classes/LiveQueryWindowControllerDisposedError.md index fcaa828ba8..4b125f3151 100644 --- a/docs/reference/classes/LiveQueryWindowControllerDisposedError.md +++ b/docs/reference/classes/LiveQueryWindowControllerDisposedError.md @@ -3,7 +3,7 @@ id: LiveQueryWindowControllerDisposedError title: LiveQueryWindowControllerDisposedError --- -Defined in: [packages/db/src/errors.ts:192](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L192) +Defined in: [packages/db/src/errors.ts:222](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L222) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:192](https://github.com/TanStack/db/blob/ new LiveQueryWindowControllerDisposedError(): LiveQueryWindowControllerDisposedError; ``` -Defined in: [packages/db/src/errors.ts:193](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L193) +Defined in: [packages/db/src/errors.ts:223](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L223) #### Returns diff --git a/docs/reference/classes/LoadSubsetOperationAbortedError.md b/docs/reference/classes/LoadSubsetOperationAbortedError.md index 40258450dc..b65e1a933b 100644 --- a/docs/reference/classes/LoadSubsetOperationAbortedError.md +++ b/docs/reference/classes/LoadSubsetOperationAbortedError.md @@ -3,7 +3,7 @@ id: LoadSubsetOperationAbortedError title: LoadSubsetOperationAbortedError --- -Defined in: [packages/db/src/errors.ts:1080](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1080) +Defined in: [packages/db/src/errors.ts:1110](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1110) A subset operation was canceled before its result became visible. @@ -19,7 +19,7 @@ A subset operation was canceled before its result became visible. new LoadSubsetOperationAbortedError(): LoadSubsetOperationAbortedError; ``` -Defined in: [packages/db/src/errors.ts:1081](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1081) +Defined in: [packages/db/src/errors.ts:1111](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1111) #### Returns diff --git a/docs/reference/classes/LocalStorageCollectionError.md b/docs/reference/classes/LocalStorageCollectionError.md index 6555c32205..3a34169cdf 100644 --- a/docs/reference/classes/LocalStorageCollectionError.md +++ b/docs/reference/classes/LocalStorageCollectionError.md @@ -3,7 +3,7 @@ id: LocalStorageCollectionError title: LocalStorageCollectionError --- -Defined in: [packages/db/src/errors.ts:1006](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1006) +Defined in: [packages/db/src/errors.ts:1036](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1036) ## Extends @@ -23,7 +23,7 @@ Defined in: [packages/db/src/errors.ts:1006](https://github.com/TanStack/db/blob new LocalStorageCollectionError(message): LocalStorageCollectionError; ``` -Defined in: [packages/db/src/errors.ts:1007](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1007) +Defined in: [packages/db/src/errors.ts:1037](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1037) #### Parameters diff --git a/docs/reference/classes/MissingAliasInputsError.md b/docs/reference/classes/MissingAliasInputsError.md index 97a76aa771..80fa5052b6 100644 --- a/docs/reference/classes/MissingAliasInputsError.md +++ b/docs/reference/classes/MissingAliasInputsError.md @@ -3,7 +3,7 @@ id: MissingAliasInputsError title: MissingAliasInputsError --- -Defined in: [packages/db/src/errors.ts:1113](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1113) +Defined in: [packages/db/src/errors.ts:1143](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1143) Internal error when the compiler returns aliases that don't have corresponding input streams. This should never happen since all aliases come from user declarations. @@ -20,7 +20,7 @@ This should never happen since all aliases come from user declarations. new MissingAliasInputsError(missingAliases): MissingAliasInputsError; ``` -Defined in: [packages/db/src/errors.ts:1114](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1114) +Defined in: [packages/db/src/errors.ts:1144](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1144) #### Parameters diff --git a/docs/reference/classes/MissingDeleteHandlerError.md b/docs/reference/classes/MissingDeleteHandlerError.md index 25b0f37e39..11e88b1cf7 100644 --- a/docs/reference/classes/MissingDeleteHandlerError.md +++ b/docs/reference/classes/MissingDeleteHandlerError.md @@ -3,7 +3,7 @@ id: MissingDeleteHandlerError title: MissingDeleteHandlerError --- -Defined in: [packages/db/src/errors.ts:374](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L374) +Defined in: [packages/db/src/errors.ts:404](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L404) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:374](https://github.com/TanStack/db/blob/ new MissingDeleteHandlerError(): MissingDeleteHandlerError; ``` -Defined in: [packages/db/src/errors.ts:375](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L375) +Defined in: [packages/db/src/errors.ts:405](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L405) #### Returns diff --git a/docs/reference/classes/MissingHandlerError.md b/docs/reference/classes/MissingHandlerError.md index 87ff68b125..739e2830df 100644 --- a/docs/reference/classes/MissingHandlerError.md +++ b/docs/reference/classes/MissingHandlerError.md @@ -3,7 +3,7 @@ id: MissingHandlerError title: MissingHandlerError --- -Defined in: [packages/db/src/errors.ts:347](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L347) +Defined in: [packages/db/src/errors.ts:377](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L377) ## Extends @@ -23,7 +23,7 @@ Defined in: [packages/db/src/errors.ts:347](https://github.com/TanStack/db/blob/ new MissingHandlerError(message): MissingHandlerError; ``` -Defined in: [packages/db/src/errors.ts:348](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L348) +Defined in: [packages/db/src/errors.ts:378](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L378) #### Parameters diff --git a/docs/reference/classes/MissingInsertHandlerError.md b/docs/reference/classes/MissingInsertHandlerError.md index 0f411ea387..8ab5c3e66e 100644 --- a/docs/reference/classes/MissingInsertHandlerError.md +++ b/docs/reference/classes/MissingInsertHandlerError.md @@ -3,7 +3,7 @@ id: MissingInsertHandlerError title: MissingInsertHandlerError --- -Defined in: [packages/db/src/errors.ts:354](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L354) +Defined in: [packages/db/src/errors.ts:384](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L384) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:354](https://github.com/TanStack/db/blob/ new MissingInsertHandlerError(): MissingInsertHandlerError; ``` -Defined in: [packages/db/src/errors.ts:355](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L355) +Defined in: [packages/db/src/errors.ts:385](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L385) #### Returns diff --git a/docs/reference/classes/MissingMutationFunctionError.md b/docs/reference/classes/MissingMutationFunctionError.md index 916fab7ce4..5bb19da94b 100644 --- a/docs/reference/classes/MissingMutationFunctionError.md +++ b/docs/reference/classes/MissingMutationFunctionError.md @@ -3,7 +3,7 @@ id: MissingMutationFunctionError title: MissingMutationFunctionError --- -Defined in: [packages/db/src/errors.ts:447](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L447) +Defined in: [packages/db/src/errors.ts:477](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L477) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:447](https://github.com/TanStack/db/blob/ new MissingMutationFunctionError(): MissingMutationFunctionError; ``` -Defined in: [packages/db/src/errors.ts:448](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L448) +Defined in: [packages/db/src/errors.ts:478](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L478) #### Returns diff --git a/docs/reference/classes/MissingUpdateArgumentError.md b/docs/reference/classes/MissingUpdateArgumentError.md index 05a6d312c3..56a4a85483 100644 --- a/docs/reference/classes/MissingUpdateArgumentError.md +++ b/docs/reference/classes/MissingUpdateArgumentError.md @@ -3,7 +3,7 @@ id: MissingUpdateArgumentError title: MissingUpdateArgumentError --- -Defined in: [packages/db/src/errors.ts:286](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L286) +Defined in: [packages/db/src/errors.ts:316](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L316) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:286](https://github.com/TanStack/db/blob/ new MissingUpdateArgumentError(): MissingUpdateArgumentError; ``` -Defined in: [packages/db/src/errors.ts:287](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L287) +Defined in: [packages/db/src/errors.ts:317](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L317) #### Returns diff --git a/docs/reference/classes/MissingUpdateHandlerError.md b/docs/reference/classes/MissingUpdateHandlerError.md index 7b3e2ca9a4..a9aa9b29d6 100644 --- a/docs/reference/classes/MissingUpdateHandlerError.md +++ b/docs/reference/classes/MissingUpdateHandlerError.md @@ -3,7 +3,7 @@ id: MissingUpdateHandlerError title: MissingUpdateHandlerError --- -Defined in: [packages/db/src/errors.ts:364](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L364) +Defined in: [packages/db/src/errors.ts:394](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L394) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:364](https://github.com/TanStack/db/blob/ new MissingUpdateHandlerError(): MissingUpdateHandlerError; ``` -Defined in: [packages/db/src/errors.ts:365](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L365) +Defined in: [packages/db/src/errors.ts:395](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L395) #### Returns diff --git a/docs/reference/classes/NegativeActiveSubscribersError.md b/docs/reference/classes/NegativeActiveSubscribersError.md index 283a15f861..80cc48041f 100644 --- a/docs/reference/classes/NegativeActiveSubscribersError.md +++ b/docs/reference/classes/NegativeActiveSubscribersError.md @@ -3,7 +3,7 @@ id: NegativeActiveSubscribersError title: NegativeActiveSubscribersError --- -Defined in: [packages/db/src/errors.ts:172](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L172) +Defined in: [packages/db/src/errors.ts:202](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L202) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:172](https://github.com/TanStack/db/blob/ new NegativeActiveSubscribersError(): NegativeActiveSubscribersError; ``` -Defined in: [packages/db/src/errors.ts:173](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L173) +Defined in: [packages/db/src/errors.ts:203](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L203) #### Returns diff --git a/docs/reference/classes/NoKeysPassedToDeleteError.md b/docs/reference/classes/NoKeysPassedToDeleteError.md index 0b6398ac34..a53d014b6f 100644 --- a/docs/reference/classes/NoKeysPassedToDeleteError.md +++ b/docs/reference/classes/NoKeysPassedToDeleteError.md @@ -3,7 +3,7 @@ id: NoKeysPassedToDeleteError title: NoKeysPassedToDeleteError --- -Defined in: [packages/db/src/errors.ts:326](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L326) +Defined in: [packages/db/src/errors.ts:356](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L356) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:326](https://github.com/TanStack/db/blob/ new NoKeysPassedToDeleteError(): NoKeysPassedToDeleteError; ``` -Defined in: [packages/db/src/errors.ts:327](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L327) +Defined in: [packages/db/src/errors.ts:357](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L357) #### Returns diff --git a/docs/reference/classes/NoKeysPassedToUpdateError.md b/docs/reference/classes/NoKeysPassedToUpdateError.md index fed402d4fe..f9df0fe548 100644 --- a/docs/reference/classes/NoKeysPassedToUpdateError.md +++ b/docs/reference/classes/NoKeysPassedToUpdateError.md @@ -3,7 +3,7 @@ id: NoKeysPassedToUpdateError title: NoKeysPassedToUpdateError --- -Defined in: [packages/db/src/errors.ts:296](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L296) +Defined in: [packages/db/src/errors.ts:326](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L326) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:296](https://github.com/TanStack/db/blob/ new NoKeysPassedToUpdateError(): NoKeysPassedToUpdateError; ``` -Defined in: [packages/db/src/errors.ts:297](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L297) +Defined in: [packages/db/src/errors.ts:327](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L327) #### Returns diff --git a/docs/reference/classes/NoPendingSyncTransactionCommitError.md b/docs/reference/classes/NoPendingSyncTransactionCommitError.md index fd73814b86..1f1c78e1ba 100644 --- a/docs/reference/classes/NoPendingSyncTransactionCommitError.md +++ b/docs/reference/classes/NoPendingSyncTransactionCommitError.md @@ -3,7 +3,7 @@ id: NoPendingSyncTransactionCommitError title: NoPendingSyncTransactionCommitError --- -Defined in: [packages/db/src/errors.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L540) +Defined in: [packages/db/src/errors.ts:570](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L570) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:540](https://github.com/TanStack/db/blob/ new NoPendingSyncTransactionCommitError(): NoPendingSyncTransactionCommitError; ``` -Defined in: [packages/db/src/errors.ts:541](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L541) +Defined in: [packages/db/src/errors.ts:571](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L571) #### Returns diff --git a/docs/reference/classes/NoPendingSyncTransactionWriteError.md b/docs/reference/classes/NoPendingSyncTransactionWriteError.md index 9e15b254a7..4aba78d567 100644 --- a/docs/reference/classes/NoPendingSyncTransactionWriteError.md +++ b/docs/reference/classes/NoPendingSyncTransactionWriteError.md @@ -3,7 +3,7 @@ id: NoPendingSyncTransactionWriteError title: NoPendingSyncTransactionWriteError --- -Defined in: [packages/db/src/errors.ts:508](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L508) +Defined in: [packages/db/src/errors.ts:538](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L538) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:508](https://github.com/TanStack/db/blob/ new NoPendingSyncTransactionWriteError(): NoPendingSyncTransactionWriteError; ``` -Defined in: [packages/db/src/errors.ts:509](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L509) +Defined in: [packages/db/src/errors.ts:539](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L539) #### Returns diff --git a/docs/reference/classes/NonAggregateExpressionNotInGroupByError.md b/docs/reference/classes/NonAggregateExpressionNotInGroupByError.md index 64c0f0494a..3005f35446 100644 --- a/docs/reference/classes/NonAggregateExpressionNotInGroupByError.md +++ b/docs/reference/classes/NonAggregateExpressionNotInGroupByError.md @@ -3,7 +3,7 @@ id: NonAggregateExpressionNotInGroupByError title: NonAggregateExpressionNotInGroupByError --- -Defined in: [packages/db/src/errors.ts:947](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L947) +Defined in: [packages/db/src/errors.ts:977](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L977) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:947](https://github.com/TanStack/db/blob/ new NonAggregateExpressionNotInGroupByError(alias): NonAggregateExpressionNotInGroupByError; ``` -Defined in: [packages/db/src/errors.ts:948](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L948) +Defined in: [packages/db/src/errors.ts:978](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L978) #### Parameters diff --git a/docs/reference/classes/OnMutateMustBeSynchronousError.md b/docs/reference/classes/OnMutateMustBeSynchronousError.md index fd6795fad9..a00eff403d 100644 --- a/docs/reference/classes/OnMutateMustBeSynchronousError.md +++ b/docs/reference/classes/OnMutateMustBeSynchronousError.md @@ -3,7 +3,7 @@ id: OnMutateMustBeSynchronousError title: OnMutateMustBeSynchronousError --- -Defined in: [packages/db/src/errors.ts:457](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L457) +Defined in: [packages/db/src/errors.ts:487](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L487) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:457](https://github.com/TanStack/db/blob/ new OnMutateMustBeSynchronousError(): OnMutateMustBeSynchronousError; ``` -Defined in: [packages/db/src/errors.ts:458](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L458) +Defined in: [packages/db/src/errors.ts:488](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L488) #### Returns diff --git a/docs/reference/classes/OnlyOneSourceAllowedError.md b/docs/reference/classes/OnlyOneSourceAllowedError.md index b63c340bf4..9844eb2d8f 100644 --- a/docs/reference/classes/OnlyOneSourceAllowedError.md +++ b/docs/reference/classes/OnlyOneSourceAllowedError.md @@ -3,7 +3,7 @@ id: OnlyOneSourceAllowedError title: OnlyOneSourceAllowedError --- -Defined in: [packages/db/src/errors.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L583) +Defined in: [packages/db/src/errors.ts:613](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L613) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:583](https://github.com/TanStack/db/blob/ new OnlyOneSourceAllowedError(context): OnlyOneSourceAllowedError; ``` -Defined in: [packages/db/src/errors.ts:584](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L584) +Defined in: [packages/db/src/errors.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L614) #### Parameters diff --git a/docs/reference/classes/PacedTransactionManualCommitError.md b/docs/reference/classes/PacedTransactionManualCommitError.md index 7cb1cfe2f9..371ccc8c6a 100644 --- a/docs/reference/classes/PacedTransactionManualCommitError.md +++ b/docs/reference/classes/PacedTransactionManualCommitError.md @@ -3,7 +3,7 @@ id: PacedTransactionManualCommitError title: PacedTransactionManualCommitError --- -Defined in: [packages/db/src/errors.ts:392](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L392) +Defined in: [packages/db/src/errors.ts:422](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L422) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:392](https://github.com/TanStack/db/blob/ new PacedTransactionManualCommitError(): PacedTransactionManualCommitError; ``` -Defined in: [packages/db/src/errors.ts:393](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L393) +Defined in: [packages/db/src/errors.ts:423](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L423) #### Returns diff --git a/docs/reference/classes/QueryBuilderError.md b/docs/reference/classes/QueryBuilderError.md index dccf34f433..7fecc51345 100644 --- a/docs/reference/classes/QueryBuilderError.md +++ b/docs/reference/classes/QueryBuilderError.md @@ -3,7 +3,7 @@ id: QueryBuilderError title: QueryBuilderError --- -Defined in: [packages/db/src/errors.ts:576](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L576) +Defined in: [packages/db/src/errors.ts:606](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L606) ## Extends @@ -27,7 +27,7 @@ Defined in: [packages/db/src/errors.ts:576](https://github.com/TanStack/db/blob/ new QueryBuilderError(message): QueryBuilderError; ``` -Defined in: [packages/db/src/errors.ts:577](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L577) +Defined in: [packages/db/src/errors.ts:607](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L607) #### Parameters diff --git a/docs/reference/classes/QueryCompilationError.md b/docs/reference/classes/QueryCompilationError.md index 081cd0086b..c8628e2ce8 100644 --- a/docs/reference/classes/QueryCompilationError.md +++ b/docs/reference/classes/QueryCompilationError.md @@ -3,7 +3,7 @@ id: QueryCompilationError title: QueryCompilationError --- -Defined in: [packages/db/src/errors.ts:677](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L677) +Defined in: [packages/db/src/errors.ts:707](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L707) ## Extends @@ -37,7 +37,7 @@ Defined in: [packages/db/src/errors.ts:677](https://github.com/TanStack/db/blob/ new QueryCompilationError(message): QueryCompilationError; ``` -Defined in: [packages/db/src/errors.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L678) +Defined in: [packages/db/src/errors.ts:708](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L708) #### Parameters diff --git a/docs/reference/classes/QueryMustHaveFromClauseError.md b/docs/reference/classes/QueryMustHaveFromClauseError.md index efffac467e..b44291787e 100644 --- a/docs/reference/classes/QueryMustHaveFromClauseError.md +++ b/docs/reference/classes/QueryMustHaveFromClauseError.md @@ -3,7 +3,7 @@ id: QueryMustHaveFromClauseError title: QueryMustHaveFromClauseError --- -Defined in: [packages/db/src/errors.ts:651](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L651) +Defined in: [packages/db/src/errors.ts:681](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L681) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:651](https://github.com/TanStack/db/blob/ new QueryMustHaveFromClauseError(): QueryMustHaveFromClauseError; ``` -Defined in: [packages/db/src/errors.ts:652](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L652) +Defined in: [packages/db/src/errors.ts:682](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L682) #### Returns diff --git a/docs/reference/classes/QueryOptimizerError.md b/docs/reference/classes/QueryOptimizerError.md index 9951d5cb43..a698c592ce 100644 --- a/docs/reference/classes/QueryOptimizerError.md +++ b/docs/reference/classes/QueryOptimizerError.md @@ -3,7 +3,7 @@ id: QueryOptimizerError title: QueryOptimizerError --- -Defined in: [packages/db/src/errors.ts:1092](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1092) +Defined in: [packages/db/src/errors.ts:1122](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1122) ## Extends @@ -21,7 +21,7 @@ Defined in: [packages/db/src/errors.ts:1092](https://github.com/TanStack/db/blob new QueryOptimizerError(message): QueryOptimizerError; ``` -Defined in: [packages/db/src/errors.ts:1093](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1093) +Defined in: [packages/db/src/errors.ts:1123](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1123) #### Parameters diff --git a/docs/reference/classes/QueueCapacityExceededError.md b/docs/reference/classes/QueueCapacityExceededError.md index 8e9aac89d5..b761acfae9 100644 --- a/docs/reference/classes/QueueCapacityExceededError.md +++ b/docs/reference/classes/QueueCapacityExceededError.md @@ -3,7 +3,7 @@ id: QueueCapacityExceededError title: QueueCapacityExceededError --- -Defined in: [packages/db/src/errors.ts:403](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L403) +Defined in: [packages/db/src/errors.ts:433](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L433) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:403](https://github.com/TanStack/db/blob/ new QueueCapacityExceededError(): QueueCapacityExceededError; ``` -Defined in: [packages/db/src/errors.ts:404](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L404) +Defined in: [packages/db/src/errors.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L434) #### Returns diff --git a/docs/reference/classes/QueueDisposedError.md b/docs/reference/classes/QueueDisposedError.md index b14765ee11..9a4141d912 100644 --- a/docs/reference/classes/QueueDisposedError.md +++ b/docs/reference/classes/QueueDisposedError.md @@ -3,7 +3,7 @@ id: QueueDisposedError title: QueueDisposedError --- -Defined in: [packages/db/src/errors.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L414) +Defined in: [packages/db/src/errors.ts:444](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L444) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:414](https://github.com/TanStack/db/blob/ new QueueDisposedError(): QueueDisposedError; ``` -Defined in: [packages/db/src/errors.ts:415](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L415) +Defined in: [packages/db/src/errors.ts:445](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L445) #### Returns diff --git a/docs/reference/classes/SerializationError.md b/docs/reference/classes/SerializationError.md index 062bcd34b1..ec30df6fbe 100644 --- a/docs/reference/classes/SerializationError.md +++ b/docs/reference/classes/SerializationError.md @@ -3,7 +3,7 @@ id: SerializationError title: SerializationError --- -Defined in: [packages/db/src/errors.ts:995](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L995) +Defined in: [packages/db/src/errors.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1025) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:995](https://github.com/TanStack/db/blob/ new SerializationError(operation, originalError): SerializationError; ``` -Defined in: [packages/db/src/errors.ts:996](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L996) +Defined in: [packages/db/src/errors.ts:1026](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1026) #### Parameters diff --git a/docs/reference/classes/SetWindowFindOneLimitError.md b/docs/reference/classes/SetWindowFindOneLimitError.md index a060541d64..4eb3180e58 100644 --- a/docs/reference/classes/SetWindowFindOneLimitError.md +++ b/docs/reference/classes/SetWindowFindOneLimitError.md @@ -3,7 +3,7 @@ id: SetWindowFindOneLimitError title: SetWindowFindOneLimitError --- -Defined in: [packages/db/src/errors.ts:1141](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1141) +Defined in: [packages/db/src/errors.ts:1171](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1171) Error thrown when setWindow would change the limit of a findOne() query. @@ -19,7 +19,7 @@ Error thrown when setWindow would change the limit of a findOne() query. new SetWindowFindOneLimitError(): SetWindowFindOneLimitError; ``` -Defined in: [packages/db/src/errors.ts:1142](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1142) +Defined in: [packages/db/src/errors.ts:1172](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1172) #### Returns diff --git a/docs/reference/classes/SetWindowReentrancyError.md b/docs/reference/classes/SetWindowReentrancyError.md index f8a7b0285a..863fd852f6 100644 --- a/docs/reference/classes/SetWindowReentrancyError.md +++ b/docs/reference/classes/SetWindowReentrancyError.md @@ -3,7 +3,7 @@ id: SetWindowReentrancyError title: SetWindowReentrancyError --- -Defined in: [packages/db/src/errors.ts:1152](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1152) +Defined in: [packages/db/src/errors.ts:1182](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1182) Error thrown when setWindow is called from inside another setWindow call. @@ -19,7 +19,7 @@ Error thrown when setWindow is called from inside another setWindow call. new SetWindowReentrancyError(): SetWindowReentrancyError; ``` -Defined in: [packages/db/src/errors.ts:1153](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1153) +Defined in: [packages/db/src/errors.ts:1183](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1183) #### Returns diff --git a/docs/reference/classes/SetWindowRequiresOrderByError.md b/docs/reference/classes/SetWindowRequiresOrderByError.md index 9ce0624498..1cfa34eedd 100644 --- a/docs/reference/classes/SetWindowRequiresOrderByError.md +++ b/docs/reference/classes/SetWindowRequiresOrderByError.md @@ -3,7 +3,7 @@ id: SetWindowRequiresOrderByError title: SetWindowRequiresOrderByError --- -Defined in: [packages/db/src/errors.ts:1127](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1127) +Defined in: [packages/db/src/errors.ts:1157](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1157) Error thrown when setWindow is called on a collection without an ORDER BY clause. @@ -19,7 +19,7 @@ Error thrown when setWindow is called on a collection without an ORDER BY clause new SetWindowRequiresOrderByError(): SetWindowRequiresOrderByError; ``` -Defined in: [packages/db/src/errors.ts:1128](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1128) +Defined in: [packages/db/src/errors.ts:1158](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1158) #### Returns diff --git a/docs/reference/classes/StorageError.md b/docs/reference/classes/StorageError.md index 57ea54b824..b27a79ecb2 100644 --- a/docs/reference/classes/StorageError.md +++ b/docs/reference/classes/StorageError.md @@ -3,7 +3,7 @@ id: StorageError title: StorageError --- -Defined in: [packages/db/src/errors.ts:988](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L988) +Defined in: [packages/db/src/errors.ts:1018](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1018) ## Extends @@ -22,7 +22,7 @@ Defined in: [packages/db/src/errors.ts:988](https://github.com/TanStack/db/blob/ new StorageError(message): StorageError; ``` -Defined in: [packages/db/src/errors.ts:989](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L989) +Defined in: [packages/db/src/errors.ts:1019](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1019) #### Parameters diff --git a/docs/reference/classes/StorageKeyRequiredError.md b/docs/reference/classes/StorageKeyRequiredError.md index d5409d93f0..8a117370f2 100644 --- a/docs/reference/classes/StorageKeyRequiredError.md +++ b/docs/reference/classes/StorageKeyRequiredError.md @@ -3,7 +3,7 @@ id: StorageKeyRequiredError title: StorageKeyRequiredError --- -Defined in: [packages/db/src/errors.ts:1013](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1013) +Defined in: [packages/db/src/errors.ts:1043](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1043) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:1013](https://github.com/TanStack/db/blob new StorageKeyRequiredError(): StorageKeyRequiredError; ``` -Defined in: [packages/db/src/errors.ts:1014](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1014) +Defined in: [packages/db/src/errors.ts:1044](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1044) #### Returns diff --git a/docs/reference/classes/SubQueryMustHaveFromClauseError.md b/docs/reference/classes/SubQueryMustHaveFromClauseError.md index 55473ab7fb..1f3aa3542b 100644 --- a/docs/reference/classes/SubQueryMustHaveFromClauseError.md +++ b/docs/reference/classes/SubQueryMustHaveFromClauseError.md @@ -3,7 +3,7 @@ id: SubQueryMustHaveFromClauseError title: SubQueryMustHaveFromClauseError --- -Defined in: [packages/db/src/errors.ts:593](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L593) +Defined in: [packages/db/src/errors.ts:623](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L623) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:593](https://github.com/TanStack/db/blob/ new SubQueryMustHaveFromClauseError(context): SubQueryMustHaveFromClauseError; ``` -Defined in: [packages/db/src/errors.ts:594](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L594) +Defined in: [packages/db/src/errors.ts:624](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L624) #### Parameters diff --git a/docs/reference/classes/SyncCleanupError.md b/docs/reference/classes/SyncCleanupError.md index 7cb34e4aef..efaa54d67f 100644 --- a/docs/reference/classes/SyncCleanupError.md +++ b/docs/reference/classes/SyncCleanupError.md @@ -3,7 +3,7 @@ id: SyncCleanupError title: SyncCleanupError --- -Defined in: [packages/db/src/errors.ts:1044](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1044) +Defined in: [packages/db/src/errors.ts:1074](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1074) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:1044](https://github.com/TanStack/db/blob new SyncCleanupError(collectionId, error): SyncCleanupError; ``` -Defined in: [packages/db/src/errors.ts:1045](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1045) +Defined in: [packages/db/src/errors.ts:1075](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1075) #### Parameters diff --git a/docs/reference/classes/SyncQueueInvariantError.md b/docs/reference/classes/SyncQueueInvariantError.md index bba49a7ac3..bf23403b3f 100644 --- a/docs/reference/classes/SyncQueueInvariantError.md +++ b/docs/reference/classes/SyncQueueInvariantError.md @@ -3,7 +3,7 @@ id: SyncQueueInvariantError title: SyncQueueInvariantError --- -Defined in: [packages/db/src/errors.ts:565](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L565) +Defined in: [packages/db/src/errors.ts:595](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L595) An internal sync-queue invariant failed: a cancel targeted a transaction that is not the open last one, or replaying the queue invalidated a @@ -21,7 +21,7 @@ transaction. No public path should reach this. new SyncQueueInvariantError(detail): SyncQueueInvariantError; ``` -Defined in: [packages/db/src/errors.ts:566](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L566) +Defined in: [packages/db/src/errors.ts:596](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L596) #### Parameters diff --git a/docs/reference/classes/SyncRowReusedWithoutPreviousValueError.md b/docs/reference/classes/SyncRowReusedWithoutPreviousValueError.md index 675d3b093d..f8dfd13a96 100644 --- a/docs/reference/classes/SyncRowReusedWithoutPreviousValueError.md +++ b/docs/reference/classes/SyncRowReusedWithoutPreviousValueError.md @@ -3,7 +3,7 @@ id: SyncRowReusedWithoutPreviousValueError title: SyncRowReusedWithoutPreviousValueError --- -Defined in: [packages/db/src/errors.ts:528](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L528) +Defined in: [packages/db/src/errors.ts:558](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L558) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:528](https://github.com/TanStack/db/blob/ new SyncRowReusedWithoutPreviousValueError(key): SyncRowReusedWithoutPreviousValueError; ``` -Defined in: [packages/db/src/errors.ts:529](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L529) +Defined in: [packages/db/src/errors.ts:559](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L559) #### Parameters diff --git a/docs/reference/classes/SyncTransactionAbortedError.md b/docs/reference/classes/SyncTransactionAbortedError.md index 6a05e786f9..77815f83e3 100644 --- a/docs/reference/classes/SyncTransactionAbortedError.md +++ b/docs/reference/classes/SyncTransactionAbortedError.md @@ -3,7 +3,7 @@ id: SyncTransactionAbortedError title: SyncTransactionAbortedError --- -Defined in: [packages/db/src/errors.ts:1056](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1056) +Defined in: [packages/db/src/errors.ts:1086](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1086) A sync transaction was canceled before its writes became visible. @@ -19,7 +19,7 @@ A sync transaction was canceled before its writes became visible. new SyncTransactionAbortedError(): SyncTransactionAbortedError; ``` -Defined in: [packages/db/src/errors.ts:1057](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1057) +Defined in: [packages/db/src/errors.ts:1087](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1087) #### Returns diff --git a/docs/reference/classes/SyncTransactionAlreadyCommittedError.md b/docs/reference/classes/SyncTransactionAlreadyCommittedError.md index c7c3bc8933..487864ba22 100644 --- a/docs/reference/classes/SyncTransactionAlreadyCommittedError.md +++ b/docs/reference/classes/SyncTransactionAlreadyCommittedError.md @@ -3,7 +3,7 @@ id: SyncTransactionAlreadyCommittedError title: SyncTransactionAlreadyCommittedError --- -Defined in: [packages/db/src/errors.ts:550](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L550) +Defined in: [packages/db/src/errors.ts:580](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L580) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:550](https://github.com/TanStack/db/blob/ new SyncTransactionAlreadyCommittedError(): SyncTransactionAlreadyCommittedError; ``` -Defined in: [packages/db/src/errors.ts:551](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L551) +Defined in: [packages/db/src/errors.ts:581](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L581) #### Returns diff --git a/docs/reference/classes/SyncTransactionAlreadyCommittedWriteError.md b/docs/reference/classes/SyncTransactionAlreadyCommittedWriteError.md index 9150ff6442..aa110bb543 100644 --- a/docs/reference/classes/SyncTransactionAlreadyCommittedWriteError.md +++ b/docs/reference/classes/SyncTransactionAlreadyCommittedWriteError.md @@ -3,7 +3,7 @@ id: SyncTransactionAlreadyCommittedWriteError title: SyncTransactionAlreadyCommittedWriteError --- -Defined in: [packages/db/src/errors.ts:518](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L518) +Defined in: [packages/db/src/errors.ts:548](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L548) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:518](https://github.com/TanStack/db/blob/ new SyncTransactionAlreadyCommittedWriteError(): SyncTransactionAlreadyCommittedWriteError; ``` -Defined in: [packages/db/src/errors.ts:519](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L519) +Defined in: [packages/db/src/errors.ts:549](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L549) #### Returns diff --git a/docs/reference/classes/ThrottleCallDroppedError.md b/docs/reference/classes/ThrottleCallDroppedError.md index e3c1524245..c066403a09 100644 --- a/docs/reference/classes/ThrottleCallDroppedError.md +++ b/docs/reference/classes/ThrottleCallDroppedError.md @@ -3,7 +3,7 @@ id: ThrottleCallDroppedError title: ThrottleCallDroppedError --- -Defined in: [packages/db/src/errors.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L425) +Defined in: [packages/db/src/errors.ts:455](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L455) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:425](https://github.com/TanStack/db/blob/ new ThrottleCallDroppedError(): ThrottleCallDroppedError; ``` -Defined in: [packages/db/src/errors.ts:426](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L426) +Defined in: [packages/db/src/errors.ts:456](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L456) #### Returns diff --git a/docs/reference/classes/TransactionAlreadyCompletedRollbackError.md b/docs/reference/classes/TransactionAlreadyCompletedRollbackError.md index 7520b4ac61..fdef2d2afb 100644 --- a/docs/reference/classes/TransactionAlreadyCompletedRollbackError.md +++ b/docs/reference/classes/TransactionAlreadyCompletedRollbackError.md @@ -3,7 +3,7 @@ id: TransactionAlreadyCompletedRollbackError title: TransactionAlreadyCompletedRollbackError --- -Defined in: [packages/db/src/errors.ts:478](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L478) +Defined in: [packages/db/src/errors.ts:508](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L508) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:478](https://github.com/TanStack/db/blob/ new TransactionAlreadyCompletedRollbackError(): TransactionAlreadyCompletedRollbackError; ``` -Defined in: [packages/db/src/errors.ts:479](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L479) +Defined in: [packages/db/src/errors.ts:509](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L509) #### Returns diff --git a/docs/reference/classes/TransactionError.md b/docs/reference/classes/TransactionError.md index d25b2c21ff..9da5fc796d 100644 --- a/docs/reference/classes/TransactionError.md +++ b/docs/reference/classes/TransactionError.md @@ -3,7 +3,7 @@ id: TransactionError title: TransactionError --- -Defined in: [packages/db/src/errors.ts:385](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L385) +Defined in: [packages/db/src/errors.ts:415](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L415) ## Extends @@ -37,7 +37,7 @@ Defined in: [packages/db/src/errors.ts:385](https://github.com/TanStack/db/blob/ new TransactionError(message): TransactionError; ``` -Defined in: [packages/db/src/errors.ts:386](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L386) +Defined in: [packages/db/src/errors.ts:416](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L416) #### Parameters diff --git a/docs/reference/classes/TransactionNotPendingCommitError.md b/docs/reference/classes/TransactionNotPendingCommitError.md index 805c9b0463..351f36f1af 100644 --- a/docs/reference/classes/TransactionNotPendingCommitError.md +++ b/docs/reference/classes/TransactionNotPendingCommitError.md @@ -3,7 +3,7 @@ id: TransactionNotPendingCommitError title: TransactionNotPendingCommitError --- -Defined in: [packages/db/src/errors.ts:498](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L498) +Defined in: [packages/db/src/errors.ts:528](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L528) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:498](https://github.com/TanStack/db/blob/ new TransactionNotPendingCommitError(): TransactionNotPendingCommitError; ``` -Defined in: [packages/db/src/errors.ts:499](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L499) +Defined in: [packages/db/src/errors.ts:529](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L529) #### Returns diff --git a/docs/reference/classes/TransactionNotPendingMutateError.md b/docs/reference/classes/TransactionNotPendingMutateError.md index ae8f923808..500d013cb1 100644 --- a/docs/reference/classes/TransactionNotPendingMutateError.md +++ b/docs/reference/classes/TransactionNotPendingMutateError.md @@ -3,7 +3,7 @@ id: TransactionNotPendingMutateError title: TransactionNotPendingMutateError --- -Defined in: [packages/db/src/errors.ts:468](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L468) +Defined in: [packages/db/src/errors.ts:498](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L498) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:468](https://github.com/TanStack/db/blob/ new TransactionNotPendingMutateError(): TransactionNotPendingMutateError; ``` -Defined in: [packages/db/src/errors.ts:469](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L469) +Defined in: [packages/db/src/errors.ts:499](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L499) #### Returns diff --git a/docs/reference/classes/UndefinedKeyError.md b/docs/reference/classes/UndefinedKeyError.md index 72eea1e8ee..84cd595b6b 100644 --- a/docs/reference/classes/UndefinedKeyError.md +++ b/docs/reference/classes/UndefinedKeyError.md @@ -3,7 +3,7 @@ id: UndefinedKeyError title: UndefinedKeyError --- -Defined in: [packages/db/src/errors.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L210) +Defined in: [packages/db/src/errors.ts:240](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L240) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:210](https://github.com/TanStack/db/blob/ new UndefinedKeyError(item): UndefinedKeyError; ``` -Defined in: [packages/db/src/errors.ts:211](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L211) +Defined in: [packages/db/src/errors.ts:241](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L241) #### Parameters diff --git a/docs/reference/classes/UnhashableQueryIRError.md b/docs/reference/classes/UnhashableQueryIRError.md index e6ab2621e5..a6a4958970 100644 --- a/docs/reference/classes/UnhashableQueryIRError.md +++ b/docs/reference/classes/UnhashableQueryIRError.md @@ -3,7 +3,7 @@ id: UnhashableQueryIRError title: UnhashableQueryIRError --- -Defined in: [packages/db/src/errors.ts:1163](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1163) +Defined in: [packages/db/src/errors.ts:1193](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1193) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:1163](https://github.com/TanStack/db/blob new UnhashableQueryIRError(path, reason): UnhashableQueryIRError; ``` -Defined in: [packages/db/src/errors.ts:1164](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1164) +Defined in: [packages/db/src/errors.ts:1194](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1194) #### Parameters @@ -95,7 +95,7 @@ Error.name readonly path: string; ``` -Defined in: [packages/db/src/errors.ts:1165](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1165) +Defined in: [packages/db/src/errors.ts:1195](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1195) *** @@ -105,7 +105,7 @@ Defined in: [packages/db/src/errors.ts:1165](https://github.com/TanStack/db/blob readonly reason: string; ``` -Defined in: [packages/db/src/errors.ts:1166](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1166) +Defined in: [packages/db/src/errors.ts:1196](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1196) *** diff --git a/docs/reference/classes/UnknownExpressionTypeError.md b/docs/reference/classes/UnknownExpressionTypeError.md index 5ec84e8123..520f60adf3 100644 --- a/docs/reference/classes/UnknownExpressionTypeError.md +++ b/docs/reference/classes/UnknownExpressionTypeError.md @@ -3,7 +3,7 @@ id: UnknownExpressionTypeError title: UnknownExpressionTypeError --- -Defined in: [packages/db/src/errors.ts:821](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L821) +Defined in: [packages/db/src/errors.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L851) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:821](https://github.com/TanStack/db/blob/ new UnknownExpressionTypeError(type): UnknownExpressionTypeError; ``` -Defined in: [packages/db/src/errors.ts:822](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L822) +Defined in: [packages/db/src/errors.ts:852](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L852) #### Parameters diff --git a/docs/reference/classes/UnknownFunctionError.md b/docs/reference/classes/UnknownFunctionError.md index eb91c7b91a..5e80b672c1 100644 --- a/docs/reference/classes/UnknownFunctionError.md +++ b/docs/reference/classes/UnknownFunctionError.md @@ -3,7 +3,7 @@ id: UnknownFunctionError title: UnknownFunctionError --- -Defined in: [packages/db/src/errors.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L841) +Defined in: [packages/db/src/errors.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L871) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:841](https://github.com/TanStack/db/blob/ new UnknownFunctionError(functionName): UnknownFunctionError; ``` -Defined in: [packages/db/src/errors.ts:842](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L842) +Defined in: [packages/db/src/errors.ts:872](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L872) #### Parameters diff --git a/docs/reference/classes/UnknownHavingExpressionTypeError.md b/docs/reference/classes/UnknownHavingExpressionTypeError.md index d8ab35a3e9..9b4e05f66b 100644 --- a/docs/reference/classes/UnknownHavingExpressionTypeError.md +++ b/docs/reference/classes/UnknownHavingExpressionTypeError.md @@ -3,7 +3,7 @@ id: UnknownHavingExpressionTypeError title: UnknownHavingExpressionTypeError --- -Defined in: [packages/db/src/errors.ts:977](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L977) +Defined in: [packages/db/src/errors.ts:1007](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1007) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:977](https://github.com/TanStack/db/blob/ new UnknownHavingExpressionTypeError(type): UnknownHavingExpressionTypeError; ``` -Defined in: [packages/db/src/errors.ts:978](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L978) +Defined in: [packages/db/src/errors.ts:1008](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1008) #### Parameters diff --git a/docs/reference/classes/UnsafeAliasPathError.md b/docs/reference/classes/UnsafeAliasPathError.md index ce1e02fcdb..2cbba18eea 100644 --- a/docs/reference/classes/UnsafeAliasPathError.md +++ b/docs/reference/classes/UnsafeAliasPathError.md @@ -3,7 +3,7 @@ id: UnsafeAliasPathError title: UnsafeAliasPathError --- -Defined in: [packages/db/src/errors.ts:684](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L684) +Defined in: [packages/db/src/errors.ts:714](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L714) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:684](https://github.com/TanStack/db/blob/ new UnsafeAliasPathError(segment): UnsafeAliasPathError; ``` -Defined in: [packages/db/src/errors.ts:685](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L685) +Defined in: [packages/db/src/errors.ts:715](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L715) #### Parameters diff --git a/docs/reference/classes/UnsupportedAggregateFunctionError.md b/docs/reference/classes/UnsupportedAggregateFunctionError.md index 8173b0e2e5..d5b1dfdf0f 100644 --- a/docs/reference/classes/UnsupportedAggregateFunctionError.md +++ b/docs/reference/classes/UnsupportedAggregateFunctionError.md @@ -3,7 +3,7 @@ id: UnsupportedAggregateFunctionError title: UnsupportedAggregateFunctionError --- -Defined in: [packages/db/src/errors.ts:957](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L957) +Defined in: [packages/db/src/errors.ts:987](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L987) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:957](https://github.com/TanStack/db/blob/ new UnsupportedAggregateFunctionError(functionName): UnsupportedAggregateFunctionError; ``` -Defined in: [packages/db/src/errors.ts:958](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L958) +Defined in: [packages/db/src/errors.ts:988](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L988) #### Parameters diff --git a/docs/reference/classes/UnsupportedFnSelectResultError.md b/docs/reference/classes/UnsupportedFnSelectResultError.md index 5b79a8108d..341266aed3 100644 --- a/docs/reference/classes/UnsupportedFnSelectResultError.md +++ b/docs/reference/classes/UnsupportedFnSelectResultError.md @@ -3,7 +3,7 @@ id: UnsupportedFnSelectResultError title: UnsupportedFnSelectResultError --- -Defined in: [packages/db/src/errors.ts:719](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L719) +Defined in: [packages/db/src/errors.ts:749](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L749) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:719](https://github.com/TanStack/db/blob/ new UnsupportedFnSelectResultError(valueDescription): UnsupportedFnSelectResultError; ``` -Defined in: [packages/db/src/errors.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L720) +Defined in: [packages/db/src/errors.ts:750](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L750) #### Parameters diff --git a/docs/reference/classes/UnsupportedFromTypeError.md b/docs/reference/classes/UnsupportedFromTypeError.md index 2bb3716416..0df5173fd1 100644 --- a/docs/reference/classes/UnsupportedFromTypeError.md +++ b/docs/reference/classes/UnsupportedFromTypeError.md @@ -3,7 +3,7 @@ id: UnsupportedFromTypeError title: UnsupportedFromTypeError --- -Defined in: [packages/db/src/errors.ts:811](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L811) +Defined in: [packages/db/src/errors.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L841) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:811](https://github.com/TanStack/db/blob/ new UnsupportedFromTypeError(type): UnsupportedFromTypeError; ``` -Defined in: [packages/db/src/errors.ts:812](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L812) +Defined in: [packages/db/src/errors.ts:842](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L842) #### Parameters diff --git a/docs/reference/classes/UnsupportedJoinSourceTypeError.md b/docs/reference/classes/UnsupportedJoinSourceTypeError.md index f2352e4f8b..ce7248b61f 100644 --- a/docs/reference/classes/UnsupportedJoinSourceTypeError.md +++ b/docs/reference/classes/UnsupportedJoinSourceTypeError.md @@ -3,7 +3,7 @@ id: UnsupportedJoinSourceTypeError title: UnsupportedJoinSourceTypeError --- -Defined in: [packages/db/src/errors.ts:929](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L929) +Defined in: [packages/db/src/errors.ts:959](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L959) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:929](https://github.com/TanStack/db/blob/ new UnsupportedJoinSourceTypeError(type): UnsupportedJoinSourceTypeError; ``` -Defined in: [packages/db/src/errors.ts:930](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L930) +Defined in: [packages/db/src/errors.ts:960](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L960) #### Parameters diff --git a/docs/reference/classes/UnsupportedJoinTypeError.md b/docs/reference/classes/UnsupportedJoinTypeError.md index fd08b7ef5d..5215a14c85 100644 --- a/docs/reference/classes/UnsupportedJoinTypeError.md +++ b/docs/reference/classes/UnsupportedJoinTypeError.md @@ -3,7 +3,7 @@ id: UnsupportedJoinTypeError title: UnsupportedJoinTypeError --- -Defined in: [packages/db/src/errors.ts:869](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L869) +Defined in: [packages/db/src/errors.ts:899](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L899) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:869](https://github.com/TanStack/db/blob/ new UnsupportedJoinTypeError(joinType): UnsupportedJoinTypeError; ``` -Defined in: [packages/db/src/errors.ts:870](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L870) +Defined in: [packages/db/src/errors.ts:900](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L900) #### Parameters diff --git a/docs/reference/classes/UnsupportedRootScalarSelectError.md b/docs/reference/classes/UnsupportedRootScalarSelectError.md index 02d20a9454..d1f378beab 100644 --- a/docs/reference/classes/UnsupportedRootScalarSelectError.md +++ b/docs/reference/classes/UnsupportedRootScalarSelectError.md @@ -3,7 +3,7 @@ id: UnsupportedRootScalarSelectError title: UnsupportedRootScalarSelectError --- -Defined in: [packages/db/src/errors.ts:731](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L731) +Defined in: [packages/db/src/errors.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L761) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:731](https://github.com/TanStack/db/blob/ new UnsupportedRootScalarSelectError(): UnsupportedRootScalarSelectError; ``` -Defined in: [packages/db/src/errors.ts:732](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L732) +Defined in: [packages/db/src/errors.ts:762](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L762) #### Returns diff --git a/docs/reference/classes/UpdateKeyNotFoundError.md b/docs/reference/classes/UpdateKeyNotFoundError.md index 64ed3469ff..433c47f333 100644 --- a/docs/reference/classes/UpdateKeyNotFoundError.md +++ b/docs/reference/classes/UpdateKeyNotFoundError.md @@ -3,7 +3,7 @@ id: UpdateKeyNotFoundError title: UpdateKeyNotFoundError --- -Defined in: [packages/db/src/errors.ts:306](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L306) +Defined in: [packages/db/src/errors.ts:336](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L336) ## Extends @@ -17,7 +17,7 @@ Defined in: [packages/db/src/errors.ts:306](https://github.com/TanStack/db/blob/ new UpdateKeyNotFoundError(key): UpdateKeyNotFoundError; ``` -Defined in: [packages/db/src/errors.ts:307](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L307) +Defined in: [packages/db/src/errors.ts:337](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L337) #### Parameters diff --git a/docs/reference/functions/assertLiveQueryWindowManyResult.md b/docs/reference/functions/assertLiveQueryWindowManyResult.md index 5075f4953d..674906be55 100644 --- a/docs/reference/functions/assertLiveQueryWindowManyResult.md +++ b/docs/reference/functions/assertLiveQueryWindowManyResult.md @@ -7,7 +7,7 @@ title: assertLiveQueryWindowManyResult function assertLiveQueryWindowManyResult(collection): void; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:408](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L408) +Defined in: [packages/db/src/live-query-window-controller.ts:401](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L401) **`Internal`** diff --git a/docs/reference/functions/compareLiveQueryWindowDependencies.md b/docs/reference/functions/compareLiveQueryWindowDependencies.md index a86091daaf..56a0b8d3f8 100644 --- a/docs/reference/functions/compareLiveQueryWindowDependencies.md +++ b/docs/reference/functions/compareLiveQueryWindowDependencies.md @@ -7,7 +7,7 @@ title: compareLiveQueryWindowDependencies function compareLiveQueryWindowDependencies(previous, current): object; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:468](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L468) +Defined in: [packages/db/src/live-query-window-controller.ts:461](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L461) **`Internal`** diff --git a/docs/reference/functions/createCollection.md b/docs/reference/functions/createCollection.md index 492f6023ba..ccb2299a93 100644 --- a/docs/reference/functions/createCollection.md +++ b/docs/reference/functions/createCollection.md @@ -9,7 +9,7 @@ title: createCollection function createCollection<T, TKey, TUtils>(options): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & NonSingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:241](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L241) +Defined in: [packages/db/src/collection/index.ts:247](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L247) ### Type Parameters @@ -41,7 +41,7 @@ Defined in: [packages/db/src/collection/index.ts:241](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<InferSchemaOutput<T>, TKey, Exclude<TUtils, undefined>, T, InferSchemaInput<T>> & NonSingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:258](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L258) +Defined in: [packages/db/src/collection/index.ts:264](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L264) ### Type Parameters @@ -73,7 +73,7 @@ Defined in: [packages/db/src/collection/index.ts:258](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & SingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:276](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L276) +Defined in: [packages/db/src/collection/index.ts:282](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L282) ### Type Parameters @@ -105,7 +105,7 @@ Defined in: [packages/db/src/collection/index.ts:276](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & SingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:292](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L292) +Defined in: [packages/db/src/collection/index.ts:298](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L298) ### Type Parameters @@ -137,7 +137,7 @@ Defined in: [packages/db/src/collection/index.ts:292](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<T, TKey, TUtils, never, T> & NonSingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:305](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L305) +Defined in: [packages/db/src/collection/index.ts:311](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L311) ### Type Parameters @@ -169,7 +169,7 @@ Defined in: [packages/db/src/collection/index.ts:305](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<T, TKey, TUtils, never, T> & NonSingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:318](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L318) +Defined in: [packages/db/src/collection/index.ts:324](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L324) ### Type Parameters @@ -201,7 +201,7 @@ Defined in: [packages/db/src/collection/index.ts:318](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<T, TKey, TUtils, never, T> & SingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:330](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L330) +Defined in: [packages/db/src/collection/index.ts:336](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L336) ### Type Parameters @@ -233,7 +233,7 @@ Defined in: [packages/db/src/collection/index.ts:330](https://github.com/TanStac function createCollection<T, TKey, TUtils>(options): Collection<T, TKey, TUtils, never, T> & SingleResult; ``` -Defined in: [packages/db/src/collection/index.ts:343](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L343) +Defined in: [packages/db/src/collection/index.ts:349](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L349) ### Type Parameters diff --git a/docs/reference/functions/createEffect.md b/docs/reference/functions/createEffect.md index 7df4cd06a9..16906d47bd 100644 --- a/docs/reference/functions/createEffect.md +++ b/docs/reference/functions/createEffect.md @@ -7,11 +7,7 @@ title: createEffect function createEffect<TRow, TKey>(config): Effect; ``` -Defined in: [packages/db/src/query/effect.ts:202](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L202) - -Creates a reactive effect that fires handlers when rows enter, exit, or -update within a query result. Effects process deltas only — they do not -maintain or require the full materialised query result. +Defined in: [packages/db/src/query/effect.ts:226](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L226) ## Type Parameters @@ -32,18 +28,3 @@ maintain or require the full materialised query result. ## Returns [`Effect`](../interfaces/Effect.md) - -## Example - -```typescript -const effect = createEffect({ - query: (q) => q.from({ msg: messagesCollection }) - .where(({ msg }) => eq(msg.role, 'user')), - onEnter: async (event) => { - await generateResponse(event.value) - }, -}) - -// Later: stop the effect -await effect.dispose() -``` diff --git a/docs/reference/functions/createLiveQueryObserver.md b/docs/reference/functions/createLiveQueryObserver.md index 5dd16de942..fb351f3e7b 100644 --- a/docs/reference/functions/createLiveQueryObserver.md +++ b/docs/reference/functions/createLiveQueryObserver.md @@ -7,7 +7,7 @@ title: createLiveQueryObserver function createLiveQueryObserver<T, TKey>(collection, options): LiveQueryObserver<T, TKey>; ``` -Defined in: [packages/db/src/live-query-observer.ts:1203](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1203) +Defined in: [packages/db/src/live-query-observer.ts:980](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L980) **`Internal`** diff --git a/docs/reference/functions/createLiveQueryWindowController.md b/docs/reference/functions/createLiveQueryWindowController.md index e68b6a389e..65b8c8e766 100644 --- a/docs/reference/functions/createLiveQueryWindowController.md +++ b/docs/reference/functions/createLiveQueryWindowController.md @@ -7,7 +7,7 @@ title: createLiveQueryWindowController function createLiveQueryWindowController<T, TKey>(collection, options): LiveQueryWindowController<T, TKey>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:1078](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L1078) +Defined in: [packages/db/src/live-query-window-controller.ts:993](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L993) **`Internal`** diff --git a/docs/reference/functions/fetchNextLiveQueryWindowPage.md b/docs/reference/functions/fetchNextLiveQueryWindowPage.md index c0dcd27f22..0f91e3acd1 100644 --- a/docs/reference/functions/fetchNextLiveQueryWindowPage.md +++ b/docs/reference/functions/fetchNextLiveQueryWindowPage.md @@ -7,7 +7,7 @@ title: fetchNextLiveQueryWindowPage function fetchNextLiveQueryWindowPage(controller): Promise<void>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L574) +Defined in: [packages/db/src/live-query-window-controller.ts:567](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L567) **`Internal`** diff --git a/docs/reference/functions/findProblems.md b/docs/reference/functions/findProblems.md new file mode 100644 index 0000000000..d12400bf3a --- /dev/null +++ b/docs/reference/functions/findProblems.md @@ -0,0 +1,24 @@ +--- +id: findProblems +title: findProblems +--- + +```ts +function findProblems(health): HealthNode[]; +``` + +Defined in: packages/db/src/collection/health-tree.ts:77 + +Every node in a health tree that shows a problem of its own, in tree order: +a `failureReason` or an `error`. A node whose only problems are below it is +not listed; its descendants are. + +## Parameters + +### health + +[`HealthNode`](../interfaces/HealthNode.md) + +## Returns + +[`HealthNode`](../interfaces/HealthNode.md)[] diff --git a/docs/reference/functions/getLiveQueryStatusFlags.md b/docs/reference/functions/getLiveQueryStatusFlags.md index 2d1a9bacde..fd9bb7aa59 100644 --- a/docs/reference/functions/getLiveQueryStatusFlags.md +++ b/docs/reference/functions/getLiveQueryStatusFlags.md @@ -7,7 +7,7 @@ title: getLiveQueryStatusFlags function getLiveQueryStatusFlags(status): LiveQueryStatusFlags; ``` -Defined in: [packages/db/src/live-query-adapter.ts:71](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L71) +Defined in: [packages/db/src/live-query-adapter.ts:74](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L74) Derive the boolean status flags from a collection status. Adapters represent a disabled query separately (with `isReady: true`); this covers the real diff --git a/docs/reference/functions/getLiveQueryWindowCollectionWarning.md b/docs/reference/functions/getLiveQueryWindowCollectionWarning.md index 74243914be..497533a989 100644 --- a/docs/reference/functions/getLiveQueryWindowCollectionWarning.md +++ b/docs/reference/functions/getLiveQueryWindowCollectionWarning.md @@ -7,7 +7,7 @@ title: getLiveQueryWindowCollectionWarning function getLiveQueryWindowCollectionWarning(collection, expectedLimit): string | undefined; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:436](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L436) +Defined in: [packages/db/src/live-query-window-controller.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L429) **`Internal`** diff --git a/docs/reference/functions/getLiveQueryWindowInputKind.md b/docs/reference/functions/getLiveQueryWindowInputKind.md index ee6349e0bb..7219a7feaa 100644 --- a/docs/reference/functions/getLiveQueryWindowInputKind.md +++ b/docs/reference/functions/getLiveQueryWindowInputKind.md @@ -7,7 +7,7 @@ title: getLiveQueryWindowInputKind function getLiveQueryWindowInputKind(input): LiveQueryWindowInputKind; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:42](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L42) +Defined in: [packages/db/src/live-query-window-controller.ts:35](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L35) **`Internal`** diff --git a/docs/reference/functions/getPersistedReadinessSource.md b/docs/reference/functions/getPersistedReadinessSource.md deleted file mode 100644 index 2cf3ededbe..0000000000 --- a/docs/reference/functions/getPersistedReadinessSource.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -id: getPersistedReadinessSource -title: getPersistedReadinessSource ---- - -```ts -function getPersistedReadinessSource(config): - | PersistedReadinessSource - | undefined; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:20](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L20) - -## Parameters - -### config - -`object` - -## Returns - - \| [`PersistedReadinessSource`](../interfaces/PersistedReadinessSource.md) - \| `undefined` diff --git a/docs/reference/functions/hasLiveQueryWindowLeases.md b/docs/reference/functions/hasLiveQueryWindowLeases.md index 8f686fb8ce..672c2c8c70 100644 --- a/docs/reference/functions/hasLiveQueryWindowLeases.md +++ b/docs/reference/functions/hasLiveQueryWindowLeases.md @@ -7,7 +7,7 @@ title: hasLiveQueryWindowLeases function hasLiveQueryWindowLeases(target): boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:403](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L403) +Defined in: [packages/db/src/live-query-window-controller.ts:396](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L396) **`Internal`** diff --git a/docs/reference/functions/isLiveQueryWindowCollection.md b/docs/reference/functions/isLiveQueryWindowCollection.md index 94259865af..2a35050eb9 100644 --- a/docs/reference/functions/isLiveQueryWindowCollection.md +++ b/docs/reference/functions/isLiveQueryWindowCollection.md @@ -7,7 +7,7 @@ title: isLiveQueryWindowCollection function isLiveQueryWindowCollection(collection): collection is LiveQueryWindowCollection; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:421](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L421) +Defined in: [packages/db/src/live-query-window-controller.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L414) **`Internal`** diff --git a/docs/reference/functions/liveQueryWindowMatches.md b/docs/reference/functions/liveQueryWindowMatches.md index d8cb412b41..c697b0d7d7 100644 --- a/docs/reference/functions/liveQueryWindowMatches.md +++ b/docs/reference/functions/liveQueryWindowMatches.md @@ -7,7 +7,7 @@ title: liveQueryWindowMatches function liveQueryWindowMatches(collection, requiredLimit): boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:107](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L107) +Defined in: [packages/db/src/live-query-window-controller.ts:100](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L100) **`Internal`** diff --git a/docs/reference/functions/normalizeLiveQueryWindowPageSize.md b/docs/reference/functions/normalizeLiveQueryWindowPageSize.md index 867d7dc165..41f7e0f10e 100644 --- a/docs/reference/functions/normalizeLiveQueryWindowPageSize.md +++ b/docs/reference/functions/normalizeLiveQueryWindowPageSize.md @@ -7,7 +7,7 @@ title: normalizeLiveQueryWindowPageSize function normalizeLiveQueryWindowPageSize(pageSize): number; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L120) +Defined in: [packages/db/src/live-query-window-controller.ts:113](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L113) **`Internal`** diff --git a/docs/reference/functions/resolveLiveQueryWindowInput.md b/docs/reference/functions/resolveLiveQueryWindowInput.md index 8519dcf4ec..0c5093b85c 100644 --- a/docs/reference/functions/resolveLiveQueryWindowInput.md +++ b/docs/reference/functions/resolveLiveQueryWindowInput.md @@ -7,7 +7,7 @@ title: resolveLiveQueryWindowInput function resolveLiveQueryWindowInput<TContext>(input): ResolvedLiveQueryWindowInput<TContext>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:62](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L62) +Defined in: [packages/db/src/live-query-window-controller.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L55) **`Internal`** diff --git a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md b/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md index 905a176595..e4cbb69f20 100644 --- a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md +++ b/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md @@ -7,7 +7,7 @@ title: shouldPreserveLiveQueryWindowPageCount function shouldPreserveLiveQueryWindowPageCount(options): boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:487](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L487) +Defined in: [packages/db/src/live-query-window-controller.ts:480](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L480) **`Internal`** diff --git a/docs/reference/functions/withCollectionSyncConfigCleanup.md b/docs/reference/functions/withCollectionSyncConfigCleanup.md index 2444a31cbf..924d8b78a2 100644 --- a/docs/reference/functions/withCollectionSyncConfigCleanup.md +++ b/docs/reference/functions/withCollectionSyncConfigCleanup.md @@ -7,7 +7,7 @@ title: withCollectionSyncConfigCleanup function withCollectionSyncConfigCleanup<TSync>(sync, cleanup): TSync; ``` -Defined in: [packages/db/src/collection/index.ts:98](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L98) +Defined in: [packages/db/src/collection/index.ts:104](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L104) **`Internal`** diff --git a/docs/reference/functions/withCollectionSyncConfigFactory.md b/docs/reference/functions/withCollectionSyncConfigFactory.md index 4c4f41b2e7..c94fdd3fa0 100644 --- a/docs/reference/functions/withCollectionSyncConfigFactory.md +++ b/docs/reference/functions/withCollectionSyncConfigFactory.md @@ -7,7 +7,7 @@ title: withCollectionSyncConfigFactory function withCollectionSyncConfigFactory<TSync>(sync, factory): CollectionSyncConfigWithFactory<TSync>; ``` -Defined in: [packages/db/src/collection/index.ts:81](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L81) +Defined in: [packages/db/src/collection/index.ts:87](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L87) **`Internal`** diff --git a/docs/reference/index.md b/docs/reference/index.md index 486890d811..29f14617e6 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -16,12 +16,15 @@ title: "@tanstack/db" - [BTreeIndex](classes/BTreeIndex.md) - [CannotCombineEmptyExpressionListError](classes/CannotCombineEmptyExpressionListError.md) - [CollectionConfigurationError](classes/CollectionConfigurationError.md) +- [CollectionHasSubscribersError](classes/CollectionHasSubscribersError.md) - [CollectionImpl](classes/CollectionImpl.md) - [CollectionInErrorStateError](classes/CollectionInErrorStateError.md) - [CollectionInputNotFoundError](classes/CollectionInputNotFoundError.md) - [CollectionIsInErrorStateError](classes/CollectionIsInErrorStateError.md) - [CollectionOperationError](classes/CollectionOperationError.md) - [CollectionPreloadAbortedError](classes/CollectionPreloadAbortedError.md) +- [CollectionReloadCrashedError](classes/CollectionReloadCrashedError.md) +- [CollectionReloadDuringSyncEntryError](classes/CollectionReloadDuringSyncEntryError.md) - [CollectionRequiresConfigError](classes/CollectionRequiresConfigError.md) - [CollectionRequiresSyncConfigError](classes/CollectionRequiresSyncConfigError.md) - [CollectionStateError](classes/CollectionStateError.md) @@ -134,6 +137,8 @@ title: "@tanstack/db" - [ChangeMessage](interfaces/ChangeMessage.md) - [Collection](interfaces/Collection.md) - [CollectionConfig](interfaces/CollectionConfig.md) +- [CollectionDependent](interfaces/CollectionDependent.md) +- [CollectionErrorInfo](interfaces/CollectionErrorInfo.md) - [CollectionIndexMetadata](interfaces/CollectionIndexMetadata.md) - [CollectionLike](interfaces/CollectionLike.md) - [Context](interfaces/Context.md) @@ -148,6 +153,7 @@ title: "@tanstack/db" - [Effect](interfaces/Effect.md) - [EffectConfig](interfaces/EffectConfig.md) - [EffectContext](interfaces/EffectContext.md) +- [HealthNode](interfaces/HealthNode.md) - [IndexDevModeConfig](interfaces/IndexDevModeConfig.md) - [IndexedDBCollectionConfig](interfaces/IndexedDBCollectionConfig.md) - [IndexedDBCollectionUtils](interfaces/IndexedDBCollectionUtils.md) @@ -172,7 +178,6 @@ title: "@tanstack/db" - [Parser](interfaces/Parser.md) - [ParseWhereOptions](interfaces/ParseWhereOptions.md) - [PendingMutation](interfaces/PendingMutation.md) -- [PersistedReadinessSource](interfaces/PersistedReadinessSource.md) - [QueryOnceConfig](interfaces/QueryOnceConfig.md) - [QueueStrategy](interfaces/QueueStrategy.md) - [QueueStrategyOptions](interfaces/QueueStrategyOptions.md) @@ -256,7 +261,6 @@ title: "@tanstack/db" - [LiveQueryKey](type-aliases/LiveQueryKey.md) - [LiveQueryObserverListener](type-aliases/LiveQueryObserverListener.md) - [LiveQueryOptions](type-aliases/LiveQueryOptions.md) -- [LiveQueryPersistedStatus](type-aliases/LiveQueryPersistedStatus.md) - [LiveQueryWindowCollection](type-aliases/LiveQueryWindowCollection.md) - [LiveQueryWindowInputKind](type-aliases/LiveQueryWindowInputKind.md) - [LoadSubsetFn](type-aliases/LoadSubsetFn.md) @@ -276,7 +280,6 @@ title: "@tanstack/db" - [OperatorName](type-aliases/OperatorName.md) - [OptimisticChangeMessage](type-aliases/OptimisticChangeMessage.md) - [OrderByCallback](type-aliases/OrderByCallback.md) -- [PersistedReadinessSnapshot](type-aliases/PersistedReadinessSnapshot.md) - [Prettify](type-aliases/Prettify.md) - [QueryBuilder](type-aliases/QueryBuilder.md) - [QueryIdentity](type-aliases/QueryIdentity.md) @@ -327,7 +330,6 @@ title: "@tanstack/db" - [collectionOptionsClaim](variables/collectionOptionsClaim.md) - [operators](variables/operators.md) -- [PERSISTED\_READINESS](variables/PERSISTED_READINESS.md) - [Query](variables/Query.md) - [SYNC\_PERSISTENCE\_PROTOCOL](variables/SYNC_PERSISTENCE_PROTOCOL.md) - [SYNC\_PERSISTENCE\_VERSION](variables/SYNC_PERSISTENCE_VERSION.md) @@ -377,6 +379,7 @@ title: "@tanstack/db" - [extractValue](functions/extractValue.md) - [fetchNextLiveQueryWindowPage](functions/fetchNextLiveQueryWindowPage.md) - [findIndexForField](functions/findIndexForField.md) +- [findProblems](functions/findProblems.md) - [getActiveTransaction](functions/getActiveTransaction.md) - [getAll](functions/getAll.md) - [getAllKeys](functions/getAllKeys.md) @@ -387,7 +390,6 @@ title: "@tanstack/db" - [getLiveQueryWindowCollectionWarning](functions/getLiveQueryWindowCollectionWarning.md) - [getLiveQueryWindowInputKind](functions/getLiveQueryWindowInputKind.md) - [getLoadSubsetDemandKey](functions/getLoadSubsetDemandKey.md) -- [getPersistedReadinessSource](functions/getPersistedReadinessSource.md) - [getPreparedLiveQueryIdentity](functions/getPreparedLiveQueryIdentity.md) - [getPreparedLiveQuerySources](functions/getPreparedLiveQuerySources.md) - [getPublicCollection](functions/getPublicCollection.md) diff --git a/docs/reference/interfaces/BaseCollectionConfig.md b/docs/reference/interfaces/BaseCollectionConfig.md index f3ba797f22..aba5bf5624 100644 --- a/docs/reference/interfaces/BaseCollectionConfig.md +++ b/docs/reference/interfaces/BaseCollectionConfig.md @@ -3,7 +3,7 @@ id: BaseCollectionConfig title: BaseCollectionConfig --- -Defined in: [packages/db/src/types.ts:762](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L762) +Defined in: [packages/db/src/types.ts:827](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L827) ## Extended by @@ -41,7 +41,7 @@ Defined in: [packages/db/src/types.ts:762](https://github.com/TanStack/db/blob/m optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) +Defined in: [packages/db/src/types.ts:881](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L881) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -66,7 +66,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) +Defined in: [packages/db/src/types.ts:906](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L906) Optional function to compare two items. This is used to order the items in the collection. @@ -106,7 +106,7 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() optional defaultIndexType: IndexConstructor<TKey>; ``` -Defined in: [packages/db/src/types.ts:830](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L830) +Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -130,7 +130,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:1036](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1036) +Defined in: [packages/db/src/types.ts:1101](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1101) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -145,7 +145,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L795) +Defined in: [packages/db/src/types.ts:860](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L860) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -162,7 +162,7 @@ disables automatic garbage collection. getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L786) +Defined in: [packages/db/src/types.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L851) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -196,7 +196,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L775) +Defined in: [packages/db/src/types.ts:840](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L840) *** @@ -208,7 +208,7 @@ optional onDelete: | DeleteMutationFn<T, TKey, TUtils, TReturn>; ``` -Defined in: [packages/db/src/types.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1025) +Defined in: [packages/db/src/types.ts:1090](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1090) Optional asynchronous handler function called before a delete operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -281,7 +281,7 @@ optional onInsert: | InsertMutationFn<T, TKey, TUtils, TReturn>; ``` -Defined in: [packages/db/src/types.ts:904](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L904) +Defined in: [packages/db/src/types.ts:969](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L969) Optional asynchronous handler function called before an insert operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -352,7 +352,7 @@ optional onUpdate: | UpdateMutationFn<T, TKey, TUtils, TReturn>; ``` -Defined in: [packages/db/src/types.ts:966](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L966) +Defined in: [packages/db/src/types.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1031) Optional asynchronous handler function called before an update operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -425,7 +425,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L776) +Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) *** @@ -435,7 +435,7 @@ Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:806](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L806) +Defined in: [packages/db/src/types.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L871) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -458,7 +458,7 @@ false optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:850](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L850) +Defined in: [packages/db/src/types.ts:915](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L915) The mode of sync to use for the collection. @@ -480,4 +480,4 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: TUtils; ``` -Defined in: [packages/db/src/types.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1038) +Defined in: [packages/db/src/types.ts:1103](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1103) diff --git a/docs/reference/interfaces/ChangeMessage.md b/docs/reference/interfaces/ChangeMessage.md index 01b597701c..ead00cb7e4 100644 --- a/docs/reference/interfaces/ChangeMessage.md +++ b/docs/reference/interfaces/ChangeMessage.md @@ -3,7 +3,7 @@ id: ChangeMessage title: ChangeMessage --- -Defined in: [packages/db/src/types.ts:600](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L600) +Defined in: [packages/db/src/types.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L615) ## Type Parameters @@ -23,7 +23,7 @@ Defined in: [packages/db/src/types.ts:600](https://github.com/TanStack/db/blob/m key: TKey; ``` -Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L604) +Defined in: [packages/db/src/types.ts:619](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L619) *** @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/m optional metadata: Record<string, unknown>; ``` -Defined in: [packages/db/src/types.ts:608](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L608) +Defined in: [packages/db/src/types.ts:623](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L623) *** @@ -43,7 +43,7 @@ Defined in: [packages/db/src/types.ts:608](https://github.com/TanStack/db/blob/m optional previousValue: T; ``` -Defined in: [packages/db/src/types.ts:606](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L606) +Defined in: [packages/db/src/types.ts:621](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L621) *** @@ -53,7 +53,7 @@ Defined in: [packages/db/src/types.ts:606](https://github.com/TanStack/db/blob/m type: OperationType; ``` -Defined in: [packages/db/src/types.ts:607](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L607) +Defined in: [packages/db/src/types.ts:622](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L622) *** @@ -63,4 +63,4 @@ Defined in: [packages/db/src/types.ts:607](https://github.com/TanStack/db/blob/m value: T; ``` -Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) +Defined in: [packages/db/src/types.ts:620](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L620) diff --git a/docs/reference/interfaces/Collection.md b/docs/reference/interfaces/Collection.md index a2ead44190..b349541cc8 100644 --- a/docs/reference/interfaces/Collection.md +++ b/docs/reference/interfaces/Collection.md @@ -3,7 +3,7 @@ id: Collection title: Collection --- -Defined in: [packages/db/src/collection/index.ts:148](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L148) +Defined in: [packages/db/src/collection/index.ts:154](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L154) Enhanced Collection interface that includes both data type T and utilities TUtils @@ -49,7 +49,7 @@ The type for insert operations (can be different from T for schemas with default _lifecycle: CollectionLifecycleManager<T, TKey, TSchema, TInsertInput>; ``` -Defined in: [packages/db/src/collection/index.ts:411](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L411) +Defined in: [packages/db/src/collection/index.ts:417](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L417) #### Inherited from @@ -63,7 +63,7 @@ Defined in: [packages/db/src/collection/index.ts:411](https://github.com/TanStac _state: CollectionStateManager<T, TKey, TSchema, TInsertInput>; ``` -Defined in: [packages/db/src/collection/index.ts:423](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L423) +Defined in: [packages/db/src/collection/index.ts:429](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L429) #### Inherited from @@ -77,7 +77,7 @@ Defined in: [packages/db/src/collection/index.ts:423](https://github.com/TanStac _sync: CollectionSyncManager<T, TKey, TSchema, TInsertInput>; ``` -Defined in: [packages/db/src/collection/index.ts:412](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L412) +Defined in: [packages/db/src/collection/index.ts:418](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L418) #### Inherited from @@ -91,7 +91,7 @@ Defined in: [packages/db/src/collection/index.ts:412](https://github.com/TanStac config: CollectionConfig<T, TKey, TSchema, TUtils>; ``` -Defined in: [packages/db/src/collection/index.ts:402](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L402) +Defined in: [packages/db/src/collection/index.ts:408](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L408) #### Inherited from @@ -105,7 +105,7 @@ Defined in: [packages/db/src/collection/index.ts:402](https://github.com/TanStac deferDataRefresh: Promise<void> | null = null; ``` -Defined in: [packages/db/src/collection/index.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L430) +Defined in: [packages/db/src/collection/index.ts:436](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L436) When set, collection consumers should defer processing incoming data refreshes until this promise resolves. This prevents stale data from @@ -123,7 +123,7 @@ overwriting optimistic state while pending writes are being applied. id: string; ``` -Defined in: [packages/db/src/collection/index.ts:401](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L401) +Defined in: [packages/db/src/collection/index.ts:407](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L407) #### Inherited from @@ -137,7 +137,7 @@ Defined in: [packages/db/src/collection/index.ts:401](https://github.com/TanStac readonly optional singleResult: true; ``` -Defined in: [packages/db/src/collection/index.ts:156](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L156) +Defined in: [packages/db/src/collection/index.ts:162](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L162) *** @@ -147,7 +147,7 @@ Defined in: [packages/db/src/collection/index.ts:156](https://github.com/TanStac readonly utils: TUtils; ``` -Defined in: [packages/db/src/collection/index.ts:155](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L155) +Defined in: [packages/db/src/collection/index.ts:161](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L161) #### Overrides @@ -163,7 +163,7 @@ Defined in: [packages/db/src/collection/index.ts:155](https://github.com/TanStac get _layoutRevision(): number; ``` -Defined in: [packages/db/src/collection/index.ts:578](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L578) +Defined in: [packages/db/src/collection/index.ts:599](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L599) Monotonic revision of explicit layout-only publications. Internal — used to distinguish them from empty ready events. @@ -186,7 +186,7 @@ Internal — used to distinguish them from empty ready events. get _stateRevision(): number; ``` -Defined in: [packages/db/src/collection/index.ts:570](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L570) +Defined in: [packages/db/src/collection/index.ts:591](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L591) Monotonic revision of the collection's visible state; advances once per committed batch of changes and cleanup, even while nothing is subscribed. @@ -210,7 +210,7 @@ Internal — used by the live-query observer's snapshot cache. get base(): CollectionBase<TKey, TOutput>; ``` -Defined in: [packages/db/src/collection/index.ts:718](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L718) +Defined in: [packages/db/src/collection/index.ts:739](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L739) The exposed authoritative base rows, before optimistic writes are applied. Queued sync writes are excluded. Reading does not start sync. @@ -234,7 +234,7 @@ Do not mutate the backing map or its rows: that bypasses publication. get compareOptions(): StringCollationConfig; ``` -Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L918) +Defined in: [packages/db/src/collection/index.ts:939](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L939) ##### Returns @@ -246,6 +246,31 @@ Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStac *** +### health + +#### Get Signature + +```ts +get health(): HealthNode; +``` + +Defined in: [packages/db/src/collection/index.ts:569](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L569) + +The health tree for this Collection and every Collection it depends on. +A base Collection's tree is one node. It always has the same shape for a +given query, so apps can parse it. After the first ready, sync problems +appear here and the rows stay. + +##### Returns + +[`HealthNode`](HealthNode.md) + +#### Inherited from + +[`CollectionImpl`](../classes/CollectionImpl.md).[`health`](../classes/CollectionImpl.md#health) + +*** + ### indexes #### Get Signature @@ -254,7 +279,7 @@ Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStac get indexes(): Map<number, BaseIndex<TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:903](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L903) +Defined in: [packages/db/src/collection/index.ts:924](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L924) Get resolved indexes for query optimization @@ -276,7 +301,7 @@ Get resolved indexes for query optimization get isLoadingSubset(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:636](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L636) +Defined in: [packages/db/src/collection/index.ts:657](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L657) Check if the collection is currently loading more data @@ -300,7 +325,7 @@ true if the collection has pending load more operations, false otherwise get size(): number; ``` -Defined in: [packages/db/src/collection/index.ts:768](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L768) +Defined in: [packages/db/src/collection/index.ts:789](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L789) Get the current size of the collection (cached) @@ -322,7 +347,7 @@ Get the current size of the collection (cached) get state(): Map<TKey, WithVirtualProps<TOutput, TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:1095](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1095) +Defined in: [packages/db/src/collection/index.ts:1116](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1116) Gets the current state of the collection as a Map @@ -362,7 +387,7 @@ Map containing all items in the collection, with keys as identifiers get status(): CollectionStatus; ``` -Defined in: [packages/db/src/collection/index.ts:553](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L553) +Defined in: [packages/db/src/collection/index.ts:559](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L559) Gets the current status of the collection @@ -384,7 +409,7 @@ Gets the current status of the collection get subscriberCount(): number; ``` -Defined in: [packages/db/src/collection/index.ts:561](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L561) +Defined in: [packages/db/src/collection/index.ts:582](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L582) Get the number of subscribers that ask for this collection's data. A live query that has no subscriber or preload of its own does not count. @@ -399,6 +424,29 @@ query that has no subscriber or preload of its own does not count. *** +### subscribers + +#### Get Signature + +```ts +get subscribers(): readonly CollectionDependent[]; +``` + +Defined in: [packages/db/src/collection/index.ts:1322](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1322) + +Live queries, Effects, and direct subscriptions that read this +Collection. `cleanup()` refuses while a live query or Effect reads it. + +##### Returns + +readonly [`CollectionDependent`](CollectionDependent.md)[] + +#### Inherited from + +[`CollectionImpl`](../classes/CollectionImpl.md).[`subscribers`](../classes/CollectionImpl.md#subscribers) + +*** + ### toArray #### Get Signature @@ -407,7 +455,7 @@ query that has no subscriber or preload of its own does not count. get toArray(): WithVirtualProps<TOutput, TKey>[]; ``` -Defined in: [packages/db/src/collection/index.ts:1119](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1119) +Defined in: [packages/db/src/collection/index.ts:1140](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1140) Gets the current state of the collection as an Array @@ -429,7 +477,7 @@ An Array containing all items in the collection _deferPublication(): PublicationDeferral; ``` -Defined in: [packages/db/src/collection/index.ts:593](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L593) +Defined in: [packages/db/src/collection/index.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L614) Defer subscriber events until a coherent multi-Collection commit ends. @@ -449,7 +497,7 @@ Defer subscriber events until a coherent multi-Collection commit ends. _deferSyncStart(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:665](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L665) +Defined in: [packages/db/src/collection/index.ts:686](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L686) **`Internal`** @@ -463,13 +511,35 @@ Defined in: [packages/db/src/collection/index.ts:665](https://github.com/TanStac *** +### \_emitHealthChange() + +```ts +_emitHealthChange(): void; +``` + +Defined in: [packages/db/src/collection/index.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L574) + +**`Internal`** + +A Collection this one depends on changed its health tree. + +#### Returns + +`void` + +#### Inherited from + +[`CollectionImpl`](../classes/CollectionImpl.md).[`_emitHealthChange`](../classes/CollectionImpl.md#_emithealthchange) + +*** + ### \_hasHeldSyncTransaction() ```ts _hasHeldSyncTransaction(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L761) +Defined in: [packages/db/src/collection/index.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L782) Whether an accepted sync transaction is not yet visible. @@ -489,7 +559,7 @@ Whether an accepted sync transaction is not yet visible. _hasHydratedKey(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:660](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L660) +Defined in: [packages/db/src/collection/index.ts:681](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L681) **`Internal`** @@ -515,7 +585,7 @@ Defined in: [packages/db/src/collection/index.ts:660](https://github.com/TanStac _hasOptimisticOverlay(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:753](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L753) +Defined in: [packages/db/src/collection/index.ts:774](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L774) Whether a local write obscures this key's authoritative source row. @@ -541,7 +611,7 @@ Whether a local write obscures this key's authoritative source row. _hasSubscriberOrPreload(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:704](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L704) +Defined in: [packages/db/src/collection/index.ts:725](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L725) **`Internal`** @@ -563,7 +633,7 @@ Whether this Collection had a subscriber or a preload in this sync run. _markLayoutChange(): void; ``` -Defined in: [packages/db/src/collection/index.ts:588](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L588) +Defined in: [packages/db/src/collection/index.ts:609](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L609) Mark the active sync transaction as layout-changing. Internal. @@ -583,7 +653,7 @@ Mark the active sync transaction as layout-changing. Internal. _markPreload(): void; ``` -Defined in: [packages/db/src/collection/index.ts:699](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L699) +Defined in: [packages/db/src/collection/index.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L720) **`Internal`** @@ -607,7 +677,7 @@ query. The request still counts: deferred acquisition may now resume. _onCleanupStart(callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:650](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L650) +Defined in: [packages/db/src/collection/index.ts:671](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L671) **`Internal`** @@ -641,7 +711,7 @@ Subscribe to the synchronous cleanup-start boundary. _onFirstSubscriberOrPreload(listener): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:709](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L709) +Defined in: [packages/db/src/collection/index.ts:730](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L730) **`Internal`** @@ -675,7 +745,7 @@ Listen for the first subscriber or preload in this sync run. _optimisticOverlaySize(): number; ``` -Defined in: [packages/db/src/collection/index.ts:746](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L746) +Defined in: [packages/db/src/collection/index.ts:767](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L767) Maximum number of source rows whose visible projection a local write can hide. @@ -695,7 +765,7 @@ Maximum number of source rows whose visible projection a local write can hide. _resumeSyncStart(): void; ``` -Defined in: [packages/db/src/collection/index.ts:670](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L670) +Defined in: [packages/db/src/collection/index.ts:691](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L691) **`Internal`** @@ -715,7 +785,7 @@ Defined in: [packages/db/src/collection/index.ts:670](https://github.com/TanStac _setTransactionScope(transactionScope): void; ``` -Defined in: [packages/db/src/collection/index.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L655) +Defined in: [packages/db/src/collection/index.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L676) **`Internal`** @@ -741,7 +811,7 @@ Defined in: [packages/db/src/collection/index.ts:655](https://github.com/TanStac _subscribeLayoutChanges(listener): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:583](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L583) +Defined in: [packages/db/src/collection/index.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L604) Subscribe to layout-only publications. Internal observer channel. @@ -773,7 +843,7 @@ Subscribe to layout-only publications. Internal observer channel. _syncedRowMetadataKeysOutsideBase(): TKey[]; ``` -Defined in: [packages/db/src/collection/index.ts:723](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L723) +Defined in: [packages/db/src/collection/index.ts:744](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L744) **`Internal`** @@ -795,7 +865,7 @@ Applied metadata keys without a source row. iterator: IterableIterator<[TKey, WithVirtualProps<T, TKey>]>; ``` -Defined in: [packages/db/src/collection/index.ts:806](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L806) +Defined in: [packages/db/src/collection/index.ts:827](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L827) Get all entries (virtual derived state) @@ -812,10 +882,10 @@ Get all entries (virtual derived state) ### cleanup() ```ts -cleanup(): Promise<void>; +cleanup(options?): Promise<void>; ``` -Defined in: [packages/db/src/collection/index.ts:1286](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1286) +Defined in: [packages/db/src/collection/index.ts:1356](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1356) Clean up the collection by stopping sync and clearing data This can be called manually or automatically by garbage collection @@ -824,6 +894,22 @@ Wait until cleanup completes before starting a new sync run. If adapter cleanup rejects, this promise rejects after the Collection reaches its final cleaned-up state. +Rejects with `CollectionHasSubscribersError` while an active live query +(one that still has a subscriber) or an Effect reads this Collection, +because its readers would lose their data. A live query nothing reads +any more ends with this Collection's cleanup start. With +`{ dependents: true }`, it first cleans up each live query (and its own +dependents) and disposes each Effect. A direct subscription survives +cleanup and asks for data again when a new sync run starts. + +#### Parameters + +##### options? + +###### dependents? + +`boolean` + #### Returns `Promise`\<`void`\> @@ -840,7 +926,7 @@ final cleaned-up state. createIndex<TIndexType>(indexCallback, config): BaseIndex<TKey>; ``` -Defined in: [packages/db/src/collection/index.ts:872](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L872) +Defined in: [packages/db/src/collection/index.ts:893](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L893) Creates an index on a collection for faster queries. Indexes significantly improve query performance by allowing constant time lookups @@ -900,7 +986,7 @@ currentStateAsChanges(options): | ChangeMessage<WithVirtualProps<T, TKey>, TKey>[]; ``` -Defined in: [packages/db/src/collection/index.ts:1152](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1152) +Defined in: [packages/db/src/collection/index.ts:1173](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1173) Returns the current state of the collection as an array of changes @@ -948,7 +1034,7 @@ const activeChanges = collection.currentStateAsChanges({ delete(keys, config?): Transaction<any>; ``` -Defined in: [packages/db/src/collection/index.ts:1072](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1072) +Defined in: [packages/db/src/collection/index.ts:1093](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1093) Deletes one or more items from the collection @@ -1015,7 +1101,7 @@ try { entries(): IterableIterator<[TKey, WithVirtualProps<T, TKey>]>; ``` -Defined in: [packages/db/src/collection/index.ts:794](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L794) +Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) Get all entries (virtual derived state) @@ -1035,7 +1121,7 @@ Get all entries (virtual derived state) forEach(callbackfn): void; ``` -Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) +Defined in: [packages/db/src/collection/index.ts:836](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L836) Execute a callback for each entry in the collection @@ -1063,7 +1149,7 @@ get(key): | undefined; ``` -Defined in: [packages/db/src/collection/index.ts:734](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L734) +Defined in: [packages/db/src/collection/index.ts:755](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L755) Get the current value for a key (virtual derived state) @@ -1090,7 +1176,7 @@ Get the current value for a key (virtual derived state) getIndexMetadata(): CollectionIndexMetadata[]; ``` -Defined in: [packages/db/src/collection/index.ts:896](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L896) +Defined in: [packages/db/src/collection/index.ts:917](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L917) Returns a snapshot of current index metadata sorted by indexId. Persistence wrappers can use this to bootstrap index state if indexes were @@ -1112,7 +1198,7 @@ created before event listeners were attached. getKeyFromItem(item): TKey; ``` -Defined in: [packages/db/src/collection/index.ts:846](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L846) +Defined in: [packages/db/src/collection/index.ts:867](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L867) #### Parameters @@ -1136,7 +1222,7 @@ Defined in: [packages/db/src/collection/index.ts:846](https://github.com/TanStac has(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:741](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L741) +Defined in: [packages/db/src/collection/index.ts:762](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L762) Check if a key exists in the collection (virtual derived state) @@ -1164,7 +1250,7 @@ insert(data, config?): | Transaction<T>; ``` -Defined in: [packages/db/src/collection/index.ts:959](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L959) +Defined in: [packages/db/src/collection/index.ts:980](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L980) Inserts one or more items into the collection @@ -1239,7 +1325,7 @@ try { isReady(): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:628](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L628) +Defined in: [packages/db/src/collection/index.ts:649](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L649) Check if the collection is ready for use Returns true if the collection has been marked as ready by its sync implementation @@ -1273,7 +1359,7 @@ if (collection.isReady()) { keys(): IterableIterator<TKey>; ``` -Defined in: [packages/db/src/collection/index.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L775) +Defined in: [packages/db/src/collection/index.ts:796](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L796) Get all keys (virtual derived state) @@ -1293,7 +1379,7 @@ Get all keys (virtual derived state) map<U>(callbackfn): U[]; ``` -Defined in: [packages/db/src/collection/index.ts:831](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L831) +Defined in: [packages/db/src/collection/index.ts:852](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L852) Create a new array with the results of calling a function for each entry in the collection @@ -1325,7 +1411,7 @@ Create a new array with the results of calling a function for each entry in the off<T>(event, callback): void; ``` -Defined in: [packages/db/src/collection/index.ts:1261](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1261) +Defined in: [packages/db/src/collection/index.ts:1282](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1282) Unsubscribe from a collection event @@ -1338,10 +1424,12 @@ Unsubscribe from a collection event \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` @@ -1373,7 +1461,7 @@ Unsubscribe from a collection event on<T>(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:1241](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1241) +Defined in: [packages/db/src/collection/index.ts:1262](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1262) Subscribe to a collection event @@ -1386,10 +1474,12 @@ Subscribe to a collection event \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` @@ -1427,7 +1517,7 @@ Subscribe to a collection event once<T>(event, callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:1251](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1251) +Defined in: [packages/db/src/collection/index.ts:1272](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1272) Subscribe to a collection event once @@ -1440,10 +1530,12 @@ Subscribe to a collection event once \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` @@ -1481,7 +1573,7 @@ Subscribe to a collection event once onFirstReady(callback): () => void; ``` -Defined in: [packages/db/src/collection/index.ts:612](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L612) +Defined in: [packages/db/src/collection/index.ts:633](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L633) Register a callback to be executed when the collection first becomes ready Useful for preloading collections @@ -1530,7 +1622,7 @@ collection.onFirstReady(() => { preload(): Promise<void>; ``` -Defined in: [packages/db/src/collection/index.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L678) +Defined in: [packages/db/src/collection/index.ts:699](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L699) Preload the collection data by starting sync if not already started Multiple concurrent calls will share the same promise @@ -1545,13 +1637,44 @@ Multiple concurrent calls will share the same promise *** +### reload() + +```ts +reload(): Promise<void>; +``` + +Defined in: [packages/db/src/collection/index.ts:1313](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1313) + +Start a new sync run while everything visible stays: the rows, the +status, and the current error, with `reloading` on this Collection's +health node. The new run's first commit replaces the rows at once and +clears the node's problem; a `status` of `error` becomes `ready`. If the +new run fails first, the rows and status stay and the node shows the new +error. On a live query, it reloads each of its sources that has an +`error`, and then the query itself if its own node has an `error`: a +failed first load or a failed request for more data. + +Returns the promise of a reload in flight when called again. Rejects on a +`crashed` Collection: a crash is not recovered in place. Clean the +Collection up with its dependents to start it fresh. + +#### Returns + +`Promise`\<`void`\> + +#### Inherited from + +[`CollectionImpl`](../classes/CollectionImpl.md).[`reload`](../classes/CollectionImpl.md#reload) + +*** + ### removeIndex() ```ts removeIndex(indexOrId): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:887](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L887) +Defined in: [packages/db/src/collection/index.ts:908](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L908) Removes an index created with createIndex. Returns true when an index existed and was removed. @@ -1582,7 +1705,7 @@ as invalid after removal. startSyncImmediate(): void; ``` -Defined in: [packages/db/src/collection/index.ts:645](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L645) +Defined in: [packages/db/src/collection/index.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L666) Start sync immediately - internal method for compiled queries This bypasses lazy loading for special cases like live query results @@ -1604,7 +1727,7 @@ Throws during active cleanup; restart after cleanup completes instead. stateWhenReady(): Promise<Map<TKey, WithVirtualProps<T, TKey>>>; ``` -Defined in: [packages/db/src/collection/index.ts:1110](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1110) +Defined in: [packages/db/src/collection/index.ts:1131](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1131) Gets the current state of the collection as a Map once the collection is ready Resolves when `preload()` does. For a live query, that is initial-query @@ -1630,7 +1753,7 @@ Promise that resolves to a Map containing all items in the collection subscribeChanges(callback, options?): CollectionSubscription; ``` -Defined in: [packages/db/src/collection/index.ts:1204](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1204) +Defined in: [packages/db/src/collection/index.ts:1225](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1225) Subscribe to changes in the collection Changes to the same key retain their causal order within a callback. @@ -1709,7 +1832,7 @@ const subscription = collection.subscribeChanges((changes) => { subscribeChanges(callback, options?): CollectionSubscription; ``` -Defined in: [packages/db/src/collection/index.ts:1212](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1212) +Defined in: [packages/db/src/collection/index.ts:1233](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1233) Subscribe to changes in the collection Changes to the same key retain their causal order within a callback. @@ -1790,7 +1913,7 @@ const subscription = collection.subscribeChanges((changes) => { toArrayWhenReady(): Promise<WithVirtualProps<T, TKey>[]>; ``` -Defined in: [packages/db/src/collection/index.ts:1130](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1130) +Defined in: [packages/db/src/collection/index.ts:1151](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1151) Gets the current state of the collection as an Array once the collection is ready Resolves when `preload()` does. For a live query, that is initial-query @@ -1816,7 +1939,7 @@ Promise that resolves to an Array containing all items in the collection update(key, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1004](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1004) +Defined in: [packages/db/src/collection/index.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1025) Updates one or more items in the collection using a callback function @@ -1891,7 +2014,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1010](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1010) +Defined in: [packages/db/src/collection/index.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1031) Updates one or more items in the collection using a callback function @@ -1969,7 +2092,7 @@ try { update(id, callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1017](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1017) +Defined in: [packages/db/src/collection/index.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1038) Updates one or more items in the collection using a callback function @@ -2044,7 +2167,7 @@ update( callback): Transaction; ``` -Defined in: [packages/db/src/collection/index.ts:1023](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1023) +Defined in: [packages/db/src/collection/index.ts:1044](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1044) Updates one or more items in the collection using a callback function @@ -2125,7 +2248,7 @@ validateData( key?): T; ``` -Defined in: [packages/db/src/collection/index.ts:910](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L910) +Defined in: [packages/db/src/collection/index.ts:931](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L931) Validates the data against the schema @@ -2159,7 +2282,7 @@ Validates the data against the schema values(): IterableIterator<WithVirtualProps<T, TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L782) +Defined in: [packages/db/src/collection/index.ts:803](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L803) Get all values (virtual derived state) @@ -2179,7 +2302,7 @@ Get all values (virtual derived state) waitFor<T>(event, timeout?): Promise<AllCollectionEvents[T]>; ``` -Defined in: [packages/db/src/collection/index.ts:1271](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1271) +Defined in: [packages/db/src/collection/index.ts:1292](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L1292) Wait for a collection event @@ -2192,10 +2315,12 @@ Wait for a collection event \| `"status:idle"` \| `"status:loading"` \| `"status:ready"` + \| `"status:crashed"` \| `"status:cleaned-up"` \| `"status:change"` \| `"subscribers:change"` \| `"loadingSubset:change"` + \| `"health:change"` \| `"truncate"` \| `"loadedSubsets:invalidated"` \| `"index:added"` diff --git a/docs/reference/interfaces/CollectionConfig.md b/docs/reference/interfaces/CollectionConfig.md index 79b36c3d82..8b71f674d2 100644 --- a/docs/reference/interfaces/CollectionConfig.md +++ b/docs/reference/interfaces/CollectionConfig.md @@ -3,7 +3,7 @@ id: CollectionConfig title: CollectionConfig --- -Defined in: [packages/db/src/types.ts:1041](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1041) +Defined in: [packages/db/src/types.ts:1106](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1106) ## Extends @@ -35,7 +35,7 @@ Defined in: [packages/db/src/types.ts:1041](https://github.com/TanStack/db/blob/ optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) +Defined in: [packages/db/src/types.ts:881](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L881) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -64,7 +64,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) +Defined in: [packages/db/src/types.ts:906](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L906) Optional function to compare two items. This is used to order the items in the collection. @@ -108,7 +108,7 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() optional defaultIndexType: IndexConstructor<TKey>; ``` -Defined in: [packages/db/src/types.ts:830](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L830) +Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -136,7 +136,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:1036](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1036) +Defined in: [packages/db/src/types.ts:1101](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1101) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -155,7 +155,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L795) +Defined in: [packages/db/src/types.ts:860](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L860) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -176,7 +176,7 @@ disables automatic garbage collection. getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L786) +Defined in: [packages/db/src/types.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L851) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -214,7 +214,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L775) +Defined in: [packages/db/src/types.ts:840](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L840) #### Inherited from @@ -230,7 +230,7 @@ optional onDelete: | DeleteMutationFn<T, TKey, TUtils, any>; ``` -Defined in: [packages/db/src/types.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1025) +Defined in: [packages/db/src/types.ts:1090](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1090) Optional asynchronous handler function called before a delete operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -307,7 +307,7 @@ optional onInsert: | InsertMutationFn<T, TKey, TUtils, any>; ``` -Defined in: [packages/db/src/types.ts:904](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L904) +Defined in: [packages/db/src/types.ts:969](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L969) Optional asynchronous handler function called before an insert operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -382,7 +382,7 @@ optional onUpdate: | UpdateMutationFn<T, TKey, TUtils, any>; ``` -Defined in: [packages/db/src/types.ts:966](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L966) +Defined in: [packages/db/src/types.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1031) Optional asynchronous handler function called before an update operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -459,7 +459,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L776) +Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) #### Inherited from @@ -473,7 +473,7 @@ Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:806](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L806) +Defined in: [packages/db/src/types.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L871) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -500,7 +500,7 @@ false sync: SyncConfig<T, TKey>; ``` -Defined in: [packages/db/src/types.ts:1047](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1047) +Defined in: [packages/db/src/types.ts:1112](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1112) *** @@ -510,7 +510,7 @@ Defined in: [packages/db/src/types.ts:1047](https://github.com/TanStack/db/blob/ optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:850](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L850) +Defined in: [packages/db/src/types.ts:915](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L915) The mode of sync to use for the collection. @@ -536,7 +536,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: TUtils; ``` -Defined in: [packages/db/src/types.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1038) +Defined in: [packages/db/src/types.ts:1103](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1103) #### Inherited from diff --git a/docs/reference/interfaces/CollectionDependent.md b/docs/reference/interfaces/CollectionDependent.md new file mode 100644 index 0000000000..b01f04ebfa --- /dev/null +++ b/docs/reference/interfaces/CollectionDependent.md @@ -0,0 +1,62 @@ +--- +id: CollectionDependent +title: CollectionDependent +--- + +Defined in: [packages/db/src/types.ts:1182](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1182) + +Something that reads a Collection, as listed by `collection.subscribers`: +a live query, an Effect, or a direct subscription. + +## Properties + +### active + +```ts +readonly active: boolean; +``` + +Defined in: [packages/db/src/types.ts:1191](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1191) + +Whether something still reads through this dependent: an Effect or a +subscription always does; a live query does while it has a subscriber. +`cleanup()` refuses only while an active live query or Effect remains. + +*** + +### collection? + +```ts +readonly optional collection: Collection<any, any, any, any, any>; +``` + +Defined in: [packages/db/src/types.ts:1185](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1185) + +A live query's Collection; a pooled live query has none. + +*** + +### end() + +```ts +end: () => Promise<void>; +``` + +Defined in: [packages/db/src/types.ts:1196](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1196) + +End this dependent: clean up the live query with its own dependents, +dispose the Effect, or end the subscription. + +#### Returns + +`Promise`\<`void`\> + +*** + +### kind + +```ts +readonly kind: "live-query" | "effect" | "subscription"; +``` + +Defined in: [packages/db/src/types.ts:1183](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1183) diff --git a/docs/reference/interfaces/CollectionErrorInfo.md b/docs/reference/interfaces/CollectionErrorInfo.md new file mode 100644 index 0000000000..c2776cbd1f --- /dev/null +++ b/docs/reference/interfaces/CollectionErrorInfo.md @@ -0,0 +1,62 @@ +--- +id: CollectionErrorInfo +title: CollectionErrorInfo +--- + +Defined in: [packages/db/src/types.ts:789](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L789) + +Detail about a Collection problem. Every field except `cause` is plain data, +so the value can be logged or sent as JSON; `cause` keeps the original value +and is omitted from JSON. + +## Properties + +### at + +```ts +readonly at: number; +``` + +Defined in: [packages/db/src/types.ts:794](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L794) + +Epoch milliseconds when the problem was reported. + +*** + +### cause + +```ts +readonly cause: unknown; +``` + +Defined in: [packages/db/src/types.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L795) + +*** + +### code? + +```ts +readonly optional code: string; +``` + +Defined in: [packages/db/src/types.ts:792](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L792) + +*** + +### message + +```ts +readonly message: string; +``` + +Defined in: [packages/db/src/types.ts:791](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L791) + +*** + +### name + +```ts +readonly name: string; +``` + +Defined in: [packages/db/src/types.ts:790](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L790) diff --git a/docs/reference/interfaces/CollectionIndexMetadata.md b/docs/reference/interfaces/CollectionIndexMetadata.md index 1c2c1aed14..93f743f08f 100644 --- a/docs/reference/interfaces/CollectionIndexMetadata.md +++ b/docs/reference/interfaces/CollectionIndexMetadata.md @@ -3,7 +3,7 @@ id: CollectionIndexMetadata title: CollectionIndexMetadata --- -Defined in: [packages/db/src/collection/events.ts:81](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L81) +Defined in: [packages/db/src/collection/events.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L91) ## Properties @@ -13,7 +13,7 @@ Defined in: [packages/db/src/collection/events.ts:81](https://github.com/TanStac expression: BasicExpression; ``` -Defined in: [packages/db/src/collection/events.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L93) +Defined in: [packages/db/src/collection/events.ts:103](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L103) *** @@ -23,7 +23,7 @@ Defined in: [packages/db/src/collection/events.ts:93](https://github.com/TanStac indexId: number; ``` -Defined in: [packages/db/src/collection/events.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L91) +Defined in: [packages/db/src/collection/events.ts:101](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L101) *** @@ -33,7 +33,7 @@ Defined in: [packages/db/src/collection/events.ts:91](https://github.com/TanStac optional name: string; ``` -Defined in: [packages/db/src/collection/events.ts:92](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L92) +Defined in: [packages/db/src/collection/events.ts:102](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L102) *** @@ -43,7 +43,7 @@ Defined in: [packages/db/src/collection/events.ts:92](https://github.com/TanStac optional options: CollectionIndexSerializableValue; ``` -Defined in: [packages/db/src/collection/events.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L95) +Defined in: [packages/db/src/collection/events.ts:105](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L105) *** @@ -53,7 +53,7 @@ Defined in: [packages/db/src/collection/events.ts:95](https://github.com/TanStac resolver: CollectionIndexResolverMetadata; ``` -Defined in: [packages/db/src/collection/events.ts:94](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L94) +Defined in: [packages/db/src/collection/events.ts:104](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L104) *** @@ -63,7 +63,7 @@ Defined in: [packages/db/src/collection/events.ts:94](https://github.com/TanStac signature: string; ``` -Defined in: [packages/db/src/collection/events.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L90) +Defined in: [packages/db/src/collection/events.ts:100](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L100) Stable signature derived from expression + serializable options. Non-serializable option fields are intentionally omitted. @@ -76,6 +76,6 @@ Non-serializable option fields are intentionally omitted. signatureVersion: 1; ``` -Defined in: [packages/db/src/collection/events.ts:85](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L85) +Defined in: [packages/db/src/collection/events.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/collection/events.ts#L95) Version for the signature serialization contract. diff --git a/docs/reference/interfaces/CollectionLike.md b/docs/reference/interfaces/CollectionLike.md index c1bcd76359..f2ebe0046a 100644 --- a/docs/reference/interfaces/CollectionLike.md +++ b/docs/reference/interfaces/CollectionLike.md @@ -30,7 +30,7 @@ for the change events system to work compareOptions: StringCollationConfig; ``` -Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L918) +Defined in: [packages/db/src/collection/index.ts:939](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L939) #### Inherited from @@ -44,7 +44,7 @@ Defined in: [packages/db/src/collection/index.ts:918](https://github.com/TanStac id: string; ``` -Defined in: [packages/db/src/collection/index.ts:401](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L401) +Defined in: [packages/db/src/collection/index.ts:407](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L407) #### Inherited from @@ -58,7 +58,7 @@ Defined in: [packages/db/src/collection/index.ts:401](https://github.com/TanStac indexes: Map<number, BaseIndex<TKey>>; ``` -Defined in: [packages/db/src/collection/index.ts:903](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L903) +Defined in: [packages/db/src/collection/index.ts:924](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L924) #### Inherited from @@ -74,7 +74,7 @@ Pick.indexes entries(): IterableIterator<[TKey, WithVirtualProps<T, TKey>]>; ``` -Defined in: [packages/db/src/collection/index.ts:794](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L794) +Defined in: [packages/db/src/collection/index.ts:815](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L815) Get all entries (virtual derived state) @@ -98,7 +98,7 @@ get(key): | undefined; ``` -Defined in: [packages/db/src/collection/index.ts:734](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L734) +Defined in: [packages/db/src/collection/index.ts:755](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L755) Get the current value for a key (virtual derived state) @@ -127,7 +127,7 @@ Pick.get has(key): boolean; ``` -Defined in: [packages/db/src/collection/index.ts:741](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L741) +Defined in: [packages/db/src/collection/index.ts:762](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L762) Check if a key exists in the collection (virtual derived state) diff --git a/docs/reference/interfaces/CreateLiveQueryObserverOptions.md b/docs/reference/interfaces/CreateLiveQueryObserverOptions.md index 4f5786ec86..67260febb7 100644 --- a/docs/reference/interfaces/CreateLiveQueryObserverOptions.md +++ b/docs/reference/interfaces/CreateLiveQueryObserverOptions.md @@ -3,7 +3,7 @@ id: CreateLiveQueryObserverOptions title: CreateLiveQueryObserverOptions --- -Defined in: [packages/db/src/live-query-observer.ts:1170](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1170) +Defined in: [packages/db/src/live-query-observer.ts:947](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L947) ## Properties @@ -13,7 +13,7 @@ Defined in: [packages/db/src/live-query-observer.ts:1170](https://github.com/Tan optional client: DbClient; ``` -Defined in: [packages/db/src/live-query-observer.ts:1187](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1187) +Defined in: [packages/db/src/live-query-observer.ts:964](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L964) DbClient cache that owns SSR snapshots for this query identity. @@ -25,7 +25,7 @@ DbClient cache that owns SSR snapshots for this query identity. optional mode: "granular" | "wholesale"; ``` -Defined in: [packages/db/src/live-query-observer.ts:1185](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1185) +Defined in: [packages/db/src/live-query-observer.ts:962](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L962) How subscribers consume the observer: @@ -48,7 +48,7 @@ How subscribers consume the observer: optional onPreload: () => void; ``` -Defined in: [packages/db/src/live-query-observer.ts:1191](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1191) +Defined in: [packages/db/src/live-query-observer.ts:968](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L968) Resume framework-deferred query sources before a server preload. @@ -64,6 +64,6 @@ Resume framework-deferred query sources before a server preload. optional queryHash: string; ``` -Defined in: [packages/db/src/live-query-observer.ts:1189](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1189) +Defined in: [packages/db/src/live-query-observer.ts:966](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L966) Stable live-query identity used for dehydration and hydration. diff --git a/docs/reference/interfaces/CreateLiveQueryWindowControllerOptions.md b/docs/reference/interfaces/CreateLiveQueryWindowControllerOptions.md index 9c8a9a957b..27d663fc22 100644 --- a/docs/reference/interfaces/CreateLiveQueryWindowControllerOptions.md +++ b/docs/reference/interfaces/CreateLiveQueryWindowControllerOptions.md @@ -3,7 +3,7 @@ id: CreateLiveQueryWindowControllerOptions title: CreateLiveQueryWindowControllerOptions --- -Defined in: [packages/db/src/live-query-window-controller.ts:543](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L543) +Defined in: [packages/db/src/live-query-window-controller.ts:536](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L536) **`Internal`** @@ -17,7 +17,7 @@ This contract is unstable while RFC #1623 is being implemented. optional initialPageCount: number; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:549](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L549) +Defined in: [packages/db/src/live-query-window-controller.ts:542](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L542) Committed pages to preserve when a framework binding changes page shape. @@ -29,7 +29,7 @@ Committed pages to preserve when a framework binding changes page shape. optional initialPageParam: number; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:547](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L547) +Defined in: [packages/db/src/live-query-window-controller.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L540) Value of the first page's `pageParam` (default 0). @@ -41,6 +41,6 @@ Value of the first page's `pageParam` (default 0). optional pageSize: number; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:545](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L545) +Defined in: [packages/db/src/live-query-window-controller.ts:538](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L538) Rows per page (default 20). Invalid values use the default. diff --git a/docs/reference/interfaces/CurrentStateAsChangesOptions.md b/docs/reference/interfaces/CurrentStateAsChangesOptions.md index f106906d28..b1cd44dad8 100644 --- a/docs/reference/interfaces/CurrentStateAsChangesOptions.md +++ b/docs/reference/interfaces/CurrentStateAsChangesOptions.md @@ -3,7 +3,7 @@ id: CurrentStateAsChangesOptions title: CurrentStateAsChangesOptions --- -Defined in: [packages/db/src/types.ts:1190](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1190) +Defined in: [packages/db/src/types.ts:1284](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1284) Options for getting current state as changes @@ -15,7 +15,7 @@ Options for getting current state as changes optional limit: number; ``` -Defined in: [packages/db/src/types.ts:1194](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1194) +Defined in: [packages/db/src/types.ts:1288](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1288) *** @@ -25,7 +25,7 @@ Defined in: [packages/db/src/types.ts:1194](https://github.com/TanStack/db/blob/ optional optimizedOnly: boolean; ``` -Defined in: [packages/db/src/types.ts:1195](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1195) +Defined in: [packages/db/src/types.ts:1289](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1289) *** @@ -35,7 +35,7 @@ Defined in: [packages/db/src/types.ts:1195](https://github.com/TanStack/db/blob/ optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:1193](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1193) +Defined in: [packages/db/src/types.ts:1287](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1287) *** @@ -45,6 +45,6 @@ Defined in: [packages/db/src/types.ts:1193](https://github.com/TanStack/db/blob/ optional where: BasicExpression<boolean>; ``` -Defined in: [packages/db/src/types.ts:1192](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1192) +Defined in: [packages/db/src/types.ts:1286](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1286) Pre-compiled expression for filtering the current state diff --git a/docs/reference/interfaces/Effect.md b/docs/reference/interfaces/Effect.md index 810486733b..79bac18643 100644 --- a/docs/reference/interfaces/Effect.md +++ b/docs/reference/interfaces/Effect.md @@ -3,7 +3,7 @@ id: Effect title: Effect --- -Defined in: [packages/db/src/query/effect.ts:149](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L149) +Defined in: [packages/db/src/query/effect.ts:164](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L164) Handle returned by createEffect @@ -15,7 +15,7 @@ Handle returned by createEffect dispose: () => Promise<void>; ``` -Defined in: [packages/db/src/query/effect.ts:154](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L154) +Defined in: [packages/db/src/query/effect.ts:169](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L169) Dispose the effect and await in-flight handlers. Calls during one cleanup attempt, including calls from abort/release callbacks, share its outcome. @@ -32,6 +32,6 @@ attempt, including calls from abort/release callbacks, share its outcome. readonly disposed: boolean; ``` -Defined in: [packages/db/src/query/effect.ts:156](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L156) +Defined in: [packages/db/src/query/effect.ts:171](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L171) Whether this effect has been disposed diff --git a/docs/reference/interfaces/EffectConfig.md b/docs/reference/interfaces/EffectConfig.md index b6ded896a4..de7d419641 100644 --- a/docs/reference/interfaces/EffectConfig.md +++ b/docs/reference/interfaces/EffectConfig.md @@ -3,7 +3,7 @@ id: EffectConfig title: EffectConfig --- -Defined in: [packages/db/src/query/effect.ts:108](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L108) +Defined in: [packages/db/src/query/effect.ts:112](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L112) Effect configuration @@ -25,7 +25,7 @@ Effect configuration optional id: string; ``` -Defined in: [packages/db/src/query/effect.ts:113](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L113) +Defined in: [packages/db/src/query/effect.ts:117](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L117) Optional ID for debugging/tracing @@ -37,7 +37,7 @@ Optional ID for debugging/tracing optional onBatch: EffectBatchHandler<TRow, TKey>; ``` -Defined in: [packages/db/src/query/effect.ts:128](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L128) +Defined in: [packages/db/src/query/effect.ts:132](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L132) Called once per graph run with all delta events from that batch @@ -49,7 +49,7 @@ Called once per graph run with all delta events from that batch optional onEnter: EffectEventHandler<TRow, TKey>; ``` -Defined in: [packages/db/src/query/effect.ts:119](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L119) +Defined in: [packages/db/src/query/effect.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L123) Called once for each row entering the query result @@ -61,7 +61,7 @@ Called once for each row entering the query result optional onError: (error, event) => void; ``` -Defined in: [packages/db/src/query/effect.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L131) +Defined in: [packages/db/src/query/effect.ts:135](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L135) Error handler for exceptions thrown by effect callbacks @@ -87,23 +87,51 @@ Error handler for exceptions thrown by effect callbacks optional onExit: EffectEventHandler<TRow, TKey>; ``` -Defined in: [packages/db/src/query/effect.ts:125](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L125) +Defined in: [packages/db/src/query/effect.ts:129](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L129) Called once for each row exiting the query result *** +### onHealthChange()? + +```ts +optional onHealthChange: (health) => void; +``` + +Defined in: [packages/db/src/query/effect.ts:153](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L153) + +Called after the effect's initial-query readiness whenever its health tree +changes. A sync problem after initial-query readiness does not dispose the +effect; read the health tree to decide what to do. + +#### Parameters + +##### health + +[`HealthNode`](HealthNode.md) + +#### Returns + +`void` + +*** + ### onSourceError()? ```ts optional onSourceError: (error) => void; ``` -Defined in: [packages/db/src/query/effect.ts:138](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L138) +Defined in: [packages/db/src/query/effect.ts:146](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L146) -Called when a source collection enters an error or cleaned-up state. -The effect is automatically disposed after this callback fires. -If not provided, the error is logged to console.error. +Called when a source crashes, fails its first load before the effect's +initial-query readiness, or is cleaned up while the effect reads it, and +when a subset load the effect requests from a source, or its release, +fails, before or after that readiness. A cascade cleanup that ends the +effect does not call it. +The effect is automatically disposed after this callback fires. If not +provided, the error is logged to console.error. #### Parameters @@ -123,7 +151,7 @@ If not provided, the error is logged to console.error. optional onUpdate: EffectEventHandler<TRow, TKey>; ``` -Defined in: [packages/db/src/query/effect.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L122) +Defined in: [packages/db/src/query/effect.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L126) Called once for each row updating within the query result @@ -135,7 +163,7 @@ Called once for each row updating within the query result query: EffectQueryInput<any>; ``` -Defined in: [packages/db/src/query/effect.ts:116](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L116) +Defined in: [packages/db/src/query/effect.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L120) Query to watch for deltas @@ -147,7 +175,7 @@ Query to watch for deltas optional skipInitial: boolean; ``` -Defined in: [packages/db/src/query/effect.ts:145](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L145) +Defined in: [packages/db/src/query/effect.ts:160](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L160) Skip deltas during initial collection load. Defaults to false (process all deltas including initial sync). diff --git a/docs/reference/interfaces/EffectContext.md b/docs/reference/interfaces/EffectContext.md index edf8cf0d4f..49801294eb 100644 --- a/docs/reference/interfaces/EffectContext.md +++ b/docs/reference/interfaces/EffectContext.md @@ -3,7 +3,7 @@ id: EffectContext title: EffectContext --- -Defined in: [packages/db/src/query/effect.ts:83](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L83) +Defined in: [packages/db/src/query/effect.ts:87](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L87) Context passed to effect handlers @@ -15,7 +15,7 @@ Context passed to effect handlers effectId: string; ``` -Defined in: [packages/db/src/query/effect.ts:85](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L85) +Defined in: [packages/db/src/query/effect.ts:89](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L89) ID of this effect (auto-generated if not provided) @@ -27,6 +27,6 @@ ID of this effect (auto-generated if not provided) signal: AbortSignal; ``` -Defined in: [packages/db/src/query/effect.ts:87](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L87) +Defined in: [packages/db/src/query/effect.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L91) Aborted when effect.dispose() is called diff --git a/docs/reference/interfaces/HealthNode.md b/docs/reference/interfaces/HealthNode.md new file mode 100644 index 0000000000..795893a69d --- /dev/null +++ b/docs/reference/interfaces/HealthNode.md @@ -0,0 +1,114 @@ +--- +id: HealthNode +title: HealthNode +--- + +Defined in: [packages/db/src/types.ts:808](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L808) + +One node of a health tree. A Collection's tree is one node; a live query's +has one node for itself and one for each source alias in query order. The +shape is fixed by the query, and nodes without a problem are present too. + +A node shows at most one problem. `failureReason`: something went wrong and +the sync adapter is still trying, so the problem clears by itself. +`error`: the first load failed, the sync adapter stopped, the Collection +crashed, or, on a live query's own node, a request for more data failed. + +## Properties + +### alias + +```ts +readonly alias: string; +``` + +Defined in: [packages/db/src/types.ts:810](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L810) + +The query alias, or `root` for the Collection the tree was read from. + +*** + +### collectionId + +```ts +readonly collectionId: string; +``` + +Defined in: [packages/db/src/types.ts:811](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L811) + +*** + +### error + +```ts +readonly error: CollectionErrorInfo | null; +``` + +Defined in: [packages/db/src/types.ts:818](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L818) + +*** + +### failureReason + +```ts +readonly failureReason: CollectionErrorInfo | null; +``` + +Defined in: [packages/db/src/types.ts:817](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L817) + +*** + +### hasErrors + +```ts +readonly hasErrors: boolean; +``` + +Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) + +This node or any node below it has a `failureReason` or an `error`. + +*** + +### kind + +```ts +readonly kind: "source" | "live-query" | "include"; +``` + +Defined in: [packages/db/src/types.ts:813](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L813) + +`include` is an include's source; its rows feed per-row child Collections. + +*** + +### reloading + +```ts +readonly reloading: boolean; +``` + +Defined in: [packages/db/src/types.ts:823](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L823) + +A reload's new sync run is in flight: the rows, status, and problem shown +are the previous run's until it commits or fails. + +*** + +### sources + +```ts +readonly sources: readonly HealthNode[]; +``` + +Defined in: [packages/db/src/types.ts:824](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L824) + +*** + +### status + +```ts +readonly status: CollectionStatus; +``` + +Defined in: [packages/db/src/types.ts:814](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L814) diff --git a/docs/reference/interfaces/IndexedDBCollectionConfig.md b/docs/reference/interfaces/IndexedDBCollectionConfig.md index 19af11fc04..734b23e612 100644 --- a/docs/reference/interfaces/IndexedDBCollectionConfig.md +++ b/docs/reference/interfaces/IndexedDBCollectionConfig.md @@ -33,7 +33,7 @@ Configuration options for creating an IndexedDB Collection optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) +Defined in: [packages/db/src/types.ts:881](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L881) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -62,7 +62,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) +Defined in: [packages/db/src/types.ts:906](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L906) Optional function to compare two items. This is used to order the items in the collection. @@ -119,7 +119,7 @@ REQUIRED - must create database before collections optional defaultIndexType: IndexConstructor<TKey>; ``` -Defined in: [packages/db/src/types.ts:830](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L830) +Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -147,7 +147,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:1036](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1036) +Defined in: [packages/db/src/types.ts:1101](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1101) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -166,7 +166,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L795) +Defined in: [packages/db/src/types.ts:860](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L860) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -187,7 +187,7 @@ disables automatic garbage collection. getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L786) +Defined in: [packages/db/src/types.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L851) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -225,7 +225,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L775) +Defined in: [packages/db/src/types.ts:840](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L840) #### Inherited from @@ -254,7 +254,7 @@ optional onDelete: | DeleteMutationFn<T, TKey, UtilsRecord, any>; ``` -Defined in: [packages/db/src/types.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1025) +Defined in: [packages/db/src/types.ts:1090](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1090) Optional asynchronous handler function called before a delete operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -331,7 +331,7 @@ optional onInsert: | InsertMutationFn<T, TKey, UtilsRecord, any>; ``` -Defined in: [packages/db/src/types.ts:904](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L904) +Defined in: [packages/db/src/types.ts:969](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L969) Optional asynchronous handler function called before an insert operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -406,7 +406,7 @@ optional onUpdate: | UpdateMutationFn<T, TKey, UtilsRecord, any>; ``` -Defined in: [packages/db/src/types.ts:966](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L966) +Defined in: [packages/db/src/types.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1031) Optional asynchronous handler function called before an update operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -483,7 +483,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L776) +Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) #### Inherited from @@ -497,7 +497,7 @@ Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:806](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L806) +Defined in: [packages/db/src/types.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L871) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -524,7 +524,7 @@ false optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:850](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L850) +Defined in: [packages/db/src/types.ts:915](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L915) The mode of sync to use for the collection. @@ -550,7 +550,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: UtilsRecord; ``` -Defined in: [packages/db/src/types.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1038) +Defined in: [packages/db/src/types.ts:1103](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1103) #### Inherited from diff --git a/docs/reference/interfaces/InsertConfig.md b/docs/reference/interfaces/InsertConfig.md index b889553c85..0de7a5326e 100644 --- a/docs/reference/interfaces/InsertConfig.md +++ b/docs/reference/interfaces/InsertConfig.md @@ -3,7 +3,7 @@ id: InsertConfig title: InsertConfig --- -Defined in: [packages/db/src/types.ts:658](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L658) +Defined in: [packages/db/src/types.ts:673](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L673) ## Properties @@ -13,7 +13,7 @@ Defined in: [packages/db/src/types.ts:658](https://github.com/TanStack/db/blob/m optional metadata: Record<string, unknown>; ``` -Defined in: [packages/db/src/types.ts:659](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L659) +Defined in: [packages/db/src/types.ts:674](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L674) *** @@ -23,6 +23,6 @@ Defined in: [packages/db/src/types.ts:659](https://github.com/TanStack/db/blob/m optional optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:661](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L661) +Defined in: [packages/db/src/types.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L676) Whether to apply optimistic updates immediately. Defaults to true. diff --git a/docs/reference/interfaces/LiveQueryObserver.md b/docs/reference/interfaces/LiveQueryObserver.md index adddbafd69..20c58e96f1 100644 --- a/docs/reference/interfaces/LiveQueryObserver.md +++ b/docs/reference/interfaces/LiveQueryObserver.md @@ -3,7 +3,7 @@ id: LiveQueryObserver title: LiveQueryObserver --- -Defined in: [packages/db/src/live-query-observer.ts:124](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L124) +Defined in: [packages/db/src/live-query-observer.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L90) **`Internal`** @@ -38,7 +38,7 @@ not a public extension point yet; may change in any release. dehydrate: () => DehydratedLiveQueryResult<T, TKey>; ``` -Defined in: [packages/db/src/live-query-observer.ts:148](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L148) +Defined in: [packages/db/src/live-query-observer.ts:110](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L110) Capture the ordered query result without serializing its source collections. @@ -54,7 +54,7 @@ Capture the ordered query result without serializing its source collections. dispose: () => void; ``` -Defined in: [packages/db/src/live-query-observer.ts:150](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L150) +Defined in: [packages/db/src/live-query-observer.ts:112](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L112) Idempotent teardown. @@ -70,7 +70,7 @@ Idempotent teardown. getError: () => unknown; ``` -Defined in: [packages/db/src/live-query-observer.ts:146](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L146) +Defined in: [packages/db/src/live-query-observer.ts:108](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L108) The transport or preload error for this query, if it has not produced data. @@ -86,7 +86,7 @@ The transport or preload error for this query, if it has not produced data. getServerSnapshot: () => LiveQuerySnapshot<T, TKey>; ``` -Defined in: [packages/db/src/live-query-observer.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L131) +Defined in: [packages/db/src/live-query-observer.ts:97](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L97) Stable server snapshot used by useSyncExternalStore-style adapters. @@ -102,7 +102,7 @@ Stable server snapshot used by useSyncExternalStore-style adapters. getSnapshot: () => LiveQuerySnapshot<T, TKey>; ``` -Defined in: [packages/db/src/live-query-observer.ts:129](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L129) +Defined in: [packages/db/src/live-query-observer.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L95) Stable per-revision snapshot for wholesale materialization. @@ -112,29 +112,13 @@ Stable per-revision snapshot for wholesale materialization. *** -### isInitialRenderReady() - -```ts -isInitialRenderReady: () => boolean; -``` - -Defined in: [packages/db/src/live-query-observer.ts:144](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L144) - -Whether a persisted fallback has already passed the network-first gate. - -#### Returns - -`boolean` - -*** - ### preload() ```ts preload: () => Promise<void>; ``` -Defined in: [packages/db/src/live-query-observer.ts:140](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L140) +Defined in: [packages/db/src/live-query-observer.ts:106](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L106) Resolve once the collection has loaded its first data. @@ -144,29 +128,13 @@ Resolve once the collection has loaded its first data. *** -### preloadForInitialRender() - -```ts -preloadForInitialRender: () => Promise<void>; -``` - -Defined in: [packages/db/src/live-query-observer.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L142) - -Resolve at network readiness or a permitted persisted fallback. - -#### Returns - -`Promise`\<`void`\> - -*** - ### subscribe() ```ts subscribe: (listener) => () => void; ``` -Defined in: [packages/db/src/live-query-observer.ts:138](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L138) +Defined in: [packages/db/src/live-query-observer.ts:104](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L104) Subscribe to changes. The listener receives the change set (or `undefined` for the synthetic notify a ready collection emits on attach). Granular diff --git a/docs/reference/interfaces/LiveQuerySnapshot.md b/docs/reference/interfaces/LiveQuerySnapshot.md index aa684ee176..d47c8b703d 100644 --- a/docs/reference/interfaces/LiveQuerySnapshot.md +++ b/docs/reference/interfaces/LiveQuerySnapshot.md @@ -3,7 +3,7 @@ id: LiveQuerySnapshot title: LiveQuerySnapshot --- -Defined in: [packages/db/src/live-query-observer.ts:67](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L67) +Defined in: [packages/db/src/live-query-observer.ts:23](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L23) The canonical, adapter-agnostic view of a live query at a point in time. @@ -32,7 +32,7 @@ collection: | undefined; ``` -Defined in: [packages/db/src/live-query-observer.ts:76](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L76) +Defined in: [packages/db/src/live-query-observer.ts:32](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L32) The underlying collection, or `undefined` when disabled. @@ -44,39 +44,55 @@ The underlying collection, or `undefined` when disabled. data: T | readonly T[] | undefined; ``` -Defined in: [packages/db/src/live-query-observer.ts:74](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L74) +Defined in: [packages/db/src/live-query-observer.ts:30](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L30) Ordered results (single row for `findOne`), or `undefined` when disabled. *** +### health + +```ts +health: HealthNode | undefined; +``` + +Defined in: [packages/db/src/live-query-observer.ts:64](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L64) + +The health tree for the query and every Collection it depends on, or +`undefined` for a disabled query. After the first ready, a source +problem shows here and the data stays. + +*** + ### isCleanedUp ```ts isCleanedUp: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:97](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L97) +Defined in: [packages/db/src/live-query-observer.ts:52](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L52) *** -### isEnabled +### isCrashed ```ts -isEnabled: boolean; +isCrashed: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:98](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L98) +Defined in: [packages/db/src/live-query-observer.ts:51](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L51) + +Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. *** -### isError +### isEnabled ```ts -isError: boolean; +isEnabled: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:96](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L96) +Defined in: [packages/db/src/live-query-observer.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L53) *** @@ -86,7 +102,7 @@ Defined in: [packages/db/src/live-query-observer.ts:96](https://github.com/TanSt isIdle: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L95) +Defined in: [packages/db/src/live-query-observer.ts:47](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L47) *** @@ -96,17 +112,19 @@ Defined in: [packages/db/src/live-query-observer.ts:95](https://github.com/TanSt isLoading: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:89](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L89) +Defined in: [packages/db/src/live-query-observer.ts:45](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L45) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: boolean; +isLoadingError: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L93) +Defined in: [packages/db/src/live-query-observer.ts:49](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L49) + +The first load failed: there is nothing to show. *** @@ -116,7 +134,20 @@ Defined in: [packages/db/src/live-query-observer.ts:93](https://github.com/TanSt isReady: boolean; ``` -Defined in: [packages/db/src/live-query-observer.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L90) +Defined in: [packages/db/src/live-query-observer.ts:46](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L46) + +*** + +### isSyncError + +```ts +isSyncError: boolean; +``` + +Defined in: [packages/db/src/live-query-observer.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L58) + +The data is shown, and something keeps it from staying current: a sync +problem or a failed request somewhere in `health`. *** @@ -126,7 +157,7 @@ Defined in: [packages/db/src/live-query-observer.ts:90](https://github.com/TanSt layoutRevision: number; ``` -Defined in: [packages/db/src/live-query-observer.ts:87](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L87) +Defined in: [packages/db/src/live-query-observer.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L43) Monotonic counter bumped whenever the visible layout (the ordered key sequence) changes — membership, ordering, or an order-only move. Lets @@ -139,35 +170,13 @@ always accompanies a new snapshot, but not vice versa. *** -### persistedError - -```ts -persistedError: unknown; -``` - -Defined in: [packages/db/src/live-query-observer.ts:94](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L94) - -*** - -### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` - -Defined in: [packages/db/src/live-query-observer.ts:92](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L92) - -Persisted restore is separate from upstream/Collection readiness. - -*** - ### state ```ts state: ReadonlyMap<TKey, T> | undefined; ``` -Defined in: [packages/db/src/live-query-observer.ts:72](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L72) +Defined in: [packages/db/src/live-query-observer.ts:28](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L28) Keyed results, or `undefined` for a disabled query. @@ -179,4 +188,4 @@ Keyed results, or `undefined` for a disabled query. status: CollectionStatus | "disabled"; ``` -Defined in: [packages/db/src/live-query-observer.ts:88](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L88) +Defined in: [packages/db/src/live-query-observer.ts:44](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L44) diff --git a/docs/reference/interfaces/LiveQueryStatusFlags.md b/docs/reference/interfaces/LiveQueryStatusFlags.md index 66382ef6dc..10db1d538f 100644 --- a/docs/reference/interfaces/LiveQueryStatusFlags.md +++ b/docs/reference/interfaces/LiveQueryStatusFlags.md @@ -15,17 +15,19 @@ The derived boolean status flags every adapter exposes for a query. isCleanedUp: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:63](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L63) +Defined in: [packages/db/src/live-query-adapter.ts:66](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L66) *** -### isError +### isCrashed ```ts -isError: boolean; +isCrashed: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:62](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L62) +Defined in: [packages/db/src/live-query-adapter.ts:65](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L65) + +Local storage or an invariant broke. `status` is `crashed`. *** @@ -49,6 +51,18 @@ Defined in: [packages/db/src/live-query-adapter.ts:59](https://github.com/TanSta *** +### isLoadingError + +```ts +isLoadingError: boolean; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:63](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L63) + +The first load failed: there is nothing to show. `status` is `error`. + +*** + ### isReady ```ts diff --git a/docs/reference/interfaces/LiveQueryWindowController.md b/docs/reference/interfaces/LiveQueryWindowController.md index 83fe9e1805..2ab6910814 100644 --- a/docs/reference/interfaces/LiveQueryWindowController.md +++ b/docs/reference/interfaces/LiveQueryWindowController.md @@ -3,7 +3,7 @@ id: LiveQueryWindowController title: LiveQueryWindowController --- -Defined in: [packages/db/src/live-query-window-controller.ts:553](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L553) +Defined in: [packages/db/src/live-query-window-controller.ts:546](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L546) **`Internal`** @@ -27,7 +27,7 @@ This contract is unstable while RFC #1623 is being implemented. dispose: () => void; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:564](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L564) +Defined in: [packages/db/src/live-query-window-controller.ts:557](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L557) #### Returns @@ -41,7 +41,7 @@ Defined in: [packages/db/src/live-query-window-controller.ts:564](https://github fetchNextPage: () => Promise<void>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:560](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L560) +Defined in: [packages/db/src/live-query-window-controller.ts:553](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L553) Load one more page, resolving only after that page is committed. @@ -57,7 +57,7 @@ Load one more page, resolving only after that page is committed. getSnapshot: () => LiveQueryWindowSnapshot<T, TKey>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:557](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L557) +Defined in: [packages/db/src/live-query-window-controller.ts:550](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L550) #### Returns @@ -71,7 +71,7 @@ Defined in: [packages/db/src/live-query-window-controller.ts:557](https://github preload: () => Promise<void>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L563) +Defined in: [packages/db/src/live-query-window-controller.ts:556](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L556) #### Returns @@ -85,7 +85,7 @@ Defined in: [packages/db/src/live-query-window-controller.ts:563](https://github reset: () => Promise<void>; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:562](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L562) +Defined in: [packages/db/src/live-query-window-controller.ts:555](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L555) Reset to the first page, resolving after the smaller window is accepted. @@ -101,7 +101,7 @@ Reset to the first page, resolving after the smaller window is accepted. subscribe: (listener) => () => void; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:558](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L558) +Defined in: [packages/db/src/live-query-window-controller.ts:551](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L551) #### Parameters diff --git a/docs/reference/interfaces/LiveQueryWindowSnapshot.md b/docs/reference/interfaces/LiveQueryWindowSnapshot.md index 3239d651d2..a13299079a 100644 --- a/docs/reference/interfaces/LiveQueryWindowSnapshot.md +++ b/docs/reference/interfaces/LiveQueryWindowSnapshot.md @@ -3,7 +3,7 @@ id: LiveQueryWindowSnapshot title: LiveQueryWindowSnapshot --- -Defined in: [packages/db/src/live-query-window-controller.ts:513](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L513) +Defined in: [packages/db/src/live-query-window-controller.ts:506](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L506) **`Internal`** @@ -31,7 +31,7 @@ collection: | undefined; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:529](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L529) +Defined in: [packages/db/src/live-query-window-controller.ts:520](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L520) *** @@ -41,31 +41,31 @@ Defined in: [packages/db/src/live-query-window-controller.ts:529](https://github data: readonly T[]; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:518](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L518) +Defined in: [packages/db/src/live-query-window-controller.ts:511](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L511) Rows across all committed pages, with the peek-ahead row removed. *** -### error +### hasNextPage ```ts -error: unknown; +hasNextPage: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:526](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L526) - -Last pagination failure, retained through an earlier-started success until recovery begins. +Defined in: [packages/db/src/live-query-window-controller.ts:516](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L516) *** -### hasNextPage +### health ```ts -hasNextPage: boolean; +health: HealthNode | undefined; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:523](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L523) +Defined in: [packages/db/src/live-query-window-controller.ts:532](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L532) + +The health tree for the collection and its dependencies, or `undefined` when disabled. *** @@ -75,27 +75,27 @@ Defined in: [packages/db/src/live-query-window-controller.ts:523](https://github isCleanedUp: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:538](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L538) +Defined in: [packages/db/src/live-query-window-controller.ts:527](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L527) *** -### isEnabled +### isCrashed ```ts -isEnabled: boolean; +isCrashed: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:539](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L539) +Defined in: [packages/db/src/live-query-window-controller.ts:526](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L526) *** -### isError +### isEnabled ```ts -isError: boolean; +isEnabled: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:537](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L537) +Defined in: [packages/db/src/live-query-window-controller.ts:528](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L528) *** @@ -105,7 +105,7 @@ Defined in: [packages/db/src/live-query-window-controller.ts:537](https://github isFetchingNextPage: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:524](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L524) +Defined in: [packages/db/src/live-query-window-controller.ts:517](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L517) *** @@ -115,7 +115,7 @@ Defined in: [packages/db/src/live-query-window-controller.ts:524](https://github isIdle: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:536](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L536) +Defined in: [packages/db/src/live-query-window-controller.ts:524](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L524) *** @@ -125,17 +125,17 @@ Defined in: [packages/db/src/live-query-window-controller.ts:536](https://github isLoading: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:531](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L531) +Defined in: [packages/db/src/live-query-window-controller.ts:522](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L522) *** -### isPersistedReady +### isLoadingError ```ts -isPersistedReady: boolean; +isLoadingError: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:534](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L534) +Defined in: [packages/db/src/live-query-window-controller.ts:525](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L525) *** @@ -145,51 +145,43 @@ Defined in: [packages/db/src/live-query-window-controller.ts:534](https://github isReady: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:532](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L532) +Defined in: [packages/db/src/live-query-window-controller.ts:523](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L523) *** -### pageParams +### isSyncError ```ts -pageParams: readonly number[]; +isSyncError: boolean; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:522](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L522) +Defined in: [packages/db/src/live-query-window-controller.ts:530](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L530) -`initialPageParam + i` for each committed page. +The data is shown, and a sync problem or failed page load is in `health`. *** -### pages +### pageParams ```ts -pages: readonly readonly T[][]; +pageParams: readonly number[]; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:520](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L520) +Defined in: [packages/db/src/live-query-window-controller.ts:515](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L515) -Rows grouped into committed pages of `pageSize`. +`initialPageParam + i` for each committed page. *** -### persistedError +### pages ```ts -persistedError: unknown; +pages: readonly readonly T[][]; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:535](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L535) - -*** - -### persistedStatus - -```ts -persistedStatus: LiveQueryPersistedStatus; -``` +Defined in: [packages/db/src/live-query-window-controller.ts:513](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L513) -Defined in: [packages/db/src/live-query-window-controller.ts:533](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L533) +Rows grouped into committed pages of `pageSize`. *** @@ -199,7 +191,7 @@ Defined in: [packages/db/src/live-query-window-controller.ts:533](https://github state: ReadonlyMap<TKey, T> | undefined; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:528](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L528) +Defined in: [packages/db/src/live-query-window-controller.ts:519](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L519) Keyed results for the physical window, or `undefined` when disabled. @@ -211,4 +203,4 @@ Keyed results for the physical window, or `undefined` when disabled. status: CollectionStatus | "disabled"; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:530](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L530) +Defined in: [packages/db/src/live-query-window-controller.ts:521](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L521) diff --git a/docs/reference/interfaces/LocalOnlyCollectionConfig.md b/docs/reference/interfaces/LocalOnlyCollectionConfig.md index 8b35cf2644..1f0cacfd97 100644 --- a/docs/reference/interfaces/LocalOnlyCollectionConfig.md +++ b/docs/reference/interfaces/LocalOnlyCollectionConfig.md @@ -39,7 +39,7 @@ The type of the key returned by `getKey` optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) +Defined in: [packages/db/src/types.ts:881](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L881) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -68,7 +68,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) +Defined in: [packages/db/src/types.ts:906](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L906) Optional function to compare two items. This is used to order the items in the collection. @@ -114,7 +114,7 @@ Omit.compare optional defaultIndexType: IndexConstructor<TKey>; ``` -Defined in: [packages/db/src/types.ts:830](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L830) +Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -144,7 +144,7 @@ Omit.defaultIndexType optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:1036](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1036) +Defined in: [packages/db/src/types.ts:1101](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1101) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -165,7 +165,7 @@ Omit.defaultStringCollation getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L786) +Defined in: [packages/db/src/types.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L851) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -205,7 +205,7 @@ Omit.getKey optional id: string; ``` -Defined in: [packages/db/src/types.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L775) +Defined in: [packages/db/src/types.ts:840](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L840) #### Inherited from @@ -234,7 +234,7 @@ optional onDelete: | DeleteMutationFn<T, TKey, LocalOnlyCollectionUtils, any>; ``` -Defined in: [packages/db/src/types.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1025) +Defined in: [packages/db/src/types.ts:1090](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1090) Optional asynchronous handler function called before a delete operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -313,7 +313,7 @@ optional onInsert: | InsertMutationFn<T, TKey, LocalOnlyCollectionUtils, any>; ``` -Defined in: [packages/db/src/types.ts:904](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L904) +Defined in: [packages/db/src/types.ts:969](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L969) Optional asynchronous handler function called before an insert operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -390,7 +390,7 @@ optional onUpdate: | UpdateMutationFn<T, TKey, LocalOnlyCollectionUtils, any>; ``` -Defined in: [packages/db/src/types.ts:966](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L966) +Defined in: [packages/db/src/types.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1031) Optional asynchronous handler function called before an update operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -469,7 +469,7 @@ Omit.onUpdate optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L776) +Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) #### Inherited from @@ -485,7 +485,7 @@ Omit.schema optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:850](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L850) +Defined in: [packages/db/src/types.ts:915](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L915) The mode of sync to use for the collection. @@ -511,7 +511,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: LocalOnlyCollectionUtils; ``` -Defined in: [packages/db/src/types.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1038) +Defined in: [packages/db/src/types.ts:1103](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1103) #### Inherited from diff --git a/docs/reference/interfaces/LocalStorageCollectionConfig.md b/docs/reference/interfaces/LocalStorageCollectionConfig.md index 19aeed6b26..dde1b29668 100644 --- a/docs/reference/interfaces/LocalStorageCollectionConfig.md +++ b/docs/reference/interfaces/LocalStorageCollectionConfig.md @@ -39,7 +39,7 @@ The type of the key returned by `getKey` optional autoIndex: "eager" | "off"; ``` -Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) +Defined in: [packages/db/src/types.ts:881](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L881) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -68,7 +68,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) +Defined in: [packages/db/src/types.ts:906](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L906) Optional function to compare two items. This is used to order the items in the collection. @@ -112,7 +112,7 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() optional defaultIndexType: IndexConstructor<TKey>; ``` -Defined in: [packages/db/src/types.ts:830](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L830) +Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -140,7 +140,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:1036](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1036) +Defined in: [packages/db/src/types.ts:1101](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1101) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -159,7 +159,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:795](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L795) +Defined in: [packages/db/src/types.ts:860](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L860) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -180,7 +180,7 @@ disables automatic garbage collection. getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L786) +Defined in: [packages/db/src/types.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L851) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -218,7 +218,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:775](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L775) +Defined in: [packages/db/src/types.ts:840](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L840) #### Inherited from @@ -234,7 +234,7 @@ optional onDelete: | DeleteMutationFn<T, TKey, UtilsRecord, any>; ``` -Defined in: [packages/db/src/types.ts:1025](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1025) +Defined in: [packages/db/src/types.ts:1090](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1090) Optional asynchronous handler function called before a delete operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -311,7 +311,7 @@ optional onInsert: | InsertMutationFn<T, TKey, UtilsRecord, any>; ``` -Defined in: [packages/db/src/types.ts:904](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L904) +Defined in: [packages/db/src/types.ts:969](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L969) Optional asynchronous handler function called before an insert operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -386,7 +386,7 @@ optional onUpdate: | UpdateMutationFn<T, TKey, UtilsRecord, any>; ``` -Defined in: [packages/db/src/types.ts:966](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L966) +Defined in: [packages/db/src/types.ts:1031](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1031) Optional asynchronous handler function called before an update operation Returning a value is deprecated; coordinate synchronization through collection utilities instead. @@ -476,7 +476,7 @@ Defaults to JSON optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L776) +Defined in: [packages/db/src/types.ts:841](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L841) #### Inherited from @@ -490,7 +490,7 @@ Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:806](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L806) +Defined in: [packages/db/src/types.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L871) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -555,7 +555,7 @@ The key to use for storing the collection data in localStorage/sessionStorage optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:850](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L850) +Defined in: [packages/db/src/types.ts:915](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L915) The mode of sync to use for the collection. @@ -581,7 +581,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: UtilsRecord; ``` -Defined in: [packages/db/src/types.ts:1038](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1038) +Defined in: [packages/db/src/types.ts:1103](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1103) #### Inherited from diff --git a/docs/reference/interfaces/OperationConfig.md b/docs/reference/interfaces/OperationConfig.md index 3f1d8b4150..51aa1e2fe2 100644 --- a/docs/reference/interfaces/OperationConfig.md +++ b/docs/reference/interfaces/OperationConfig.md @@ -3,7 +3,7 @@ id: OperationConfig title: OperationConfig --- -Defined in: [packages/db/src/types.ts:652](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L652) +Defined in: [packages/db/src/types.ts:667](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L667) ## Properties @@ -13,7 +13,7 @@ Defined in: [packages/db/src/types.ts:652](https://github.com/TanStack/db/blob/m optional metadata: Record<string, unknown>; ``` -Defined in: [packages/db/src/types.ts:653](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L653) +Defined in: [packages/db/src/types.ts:668](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L668) *** @@ -23,6 +23,6 @@ Defined in: [packages/db/src/types.ts:653](https://github.com/TanStack/db/blob/m optional optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L655) +Defined in: [packages/db/src/types.ts:670](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L670) Whether to apply optimistic updates immediately. Defaults to true. diff --git a/docs/reference/interfaces/PersistedReadinessSource.md b/docs/reference/interfaces/PersistedReadinessSource.md deleted file mode 100644 index af76af3e36..0000000000 --- a/docs/reference/interfaces/PersistedReadinessSource.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -id: PersistedReadinessSource -title: PersistedReadinessSource ---- - -Defined in: [packages/db/src/persisted-readiness.ts:8](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L8) - -**`Internal`** - -Capability supplied by a persistence adapter, not by core sync. - -## Properties - -### getOrStartNetworkDeadline() - -```ts -getOrStartNetworkDeadline: () => number; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:12](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L12) - -Anchor the deadline to this Collection's current sync run. - -#### Returns - -`number` - -*** - -### getSnapshot() - -```ts -getSnapshot: () => PersistedReadinessSnapshot; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:13](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L13) - -#### Returns - -[`PersistedReadinessSnapshot`](../type-aliases/PersistedReadinessSnapshot.md) - -*** - -### networkTimeoutMs - -```ts -networkTimeoutMs: number; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:10](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L10) - -Time to prefer network before a completed persisted restore may render. - -*** - -### subscribe() - -```ts -subscribe: (listener) => () => void; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:14](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L14) - -#### Parameters - -##### listener - -() => `void` - -#### Returns - -```ts -(): void; -``` - -##### Returns - -`void` diff --git a/docs/reference/interfaces/SubscribeChangesOptions.md b/docs/reference/interfaces/SubscribeChangesOptions.md index d3fb770e49..230ff51ad5 100644 --- a/docs/reference/interfaces/SubscribeChangesOptions.md +++ b/docs/reference/interfaces/SubscribeChangesOptions.md @@ -3,7 +3,7 @@ id: SubscribeChangesOptions title: SubscribeChangesOptions --- -Defined in: [packages/db/src/types.ts:1116](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1116) +Defined in: [packages/db/src/types.ts:1202](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1202) Options for subscribing to collection changes @@ -25,7 +25,7 @@ Options for subscribing to collection changes optional deferAcquisition: boolean; ``` -Defined in: [packages/db/src/types.ts:1147](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1147) +Defined in: [packages/db/src/types.ts:1233](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1233) **`Internal`** @@ -43,7 +43,7 @@ subscriber or a preload in its current sync run. optional includeInitialState: boolean; ``` -Defined in: [packages/db/src/types.ts:1121](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1121) +Defined in: [packages/db/src/types.ts:1207](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1207) Whether to include the current state as initial changes @@ -55,7 +55,7 @@ Whether to include the current state as initial changes optional limit: number; ``` -Defined in: [packages/db/src/types.ts:1163](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1163) +Defined in: [packages/db/src/types.ts:1255](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1255) **`Internal`** @@ -69,7 +69,7 @@ Optional limit to include in loadSubset for query-specific cache keys. optional onLoadSubsetError: (event) => void; ``` -Defined in: [packages/db/src/types.ts:1171](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1171) +Defined in: [packages/db/src/types.ts:1263](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1263) **`Internal`** @@ -93,7 +93,7 @@ Receives subset-load failures scoped to this subscription. optional onLoadSubsetResult: (result) => void; ``` -Defined in: [packages/db/src/types.ts:1169](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1169) +Defined in: [packages/db/src/types.ts:1261](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1261) **`Internal`** @@ -118,7 +118,7 @@ Allows the caller to directly track the loading promise for isReady status. optional onStatusChange: (event) => void; ``` -Defined in: [packages/db/src/types.ts:1153](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1153) +Defined in: [packages/db/src/types.ts:1245](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1245) **`Internal`** @@ -143,7 +143,7 @@ Registered BEFORE any snapshot is requested, ensuring no status transitions are optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:1158](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1158) +Defined in: [packages/db/src/types.ts:1250](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1250) **`Internal`** @@ -151,18 +151,51 @@ Optional orderBy to include in loadSubset for query-specific cache keys. *** +### owner? + +```ts +optional owner: CollectionDependent; +``` + +Defined in: [packages/db/src/types.ts:1239](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1239) + +**`Internal`** + +The live query or Effect this subscription reads for, so +`collection.subscribers` can name it. + +*** + ### truncateReplayPublication? ```ts optional truncateReplayPublication: object; ``` -Defined in: [packages/db/src/types.ts:1173](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1173) +Defined in: [packages/db/src/types.ts:1265](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1265) **`Internal`** Lets a live-query graph retain its last publication during replay. +#### fail()? + +```ts +readonly optional fail: (error) => void; +``` + +The replay could not load every active demand again. + +##### Parameters + +###### error + +`Error` + +##### Returns + +`void` + #### start() ```ts @@ -191,7 +224,7 @@ readonly succeed: () => void; optional where: (row) => any; ``` -Defined in: [packages/db/src/types.ts:1136](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1136) +Defined in: [packages/db/src/types.ts:1222](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1222) Callback function for filtering changes using a row proxy. The callback receives a proxy object that records property access, @@ -225,6 +258,6 @@ collection.subscribeChanges(callback, { optional whereExpression: BasicExpression<boolean>; ``` -Defined in: [packages/db/src/types.ts:1138](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1138) +Defined in: [packages/db/src/types.ts:1224](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1224) Pre-compiled expression for filtering changes diff --git a/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md b/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md index c7c8499e9b..22b7d4d64f 100644 --- a/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md +++ b/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md @@ -3,7 +3,7 @@ id: SubscribeChangesSnapshotOptions title: SubscribeChangesSnapshotOptions --- -Defined in: [packages/db/src/types.ts:1179](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1179) +Defined in: [packages/db/src/types.ts:1273](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1273) ## Extends @@ -27,7 +27,7 @@ Defined in: [packages/db/src/types.ts:1179](https://github.com/TanStack/db/blob/ optional deferAcquisition: boolean; ``` -Defined in: [packages/db/src/types.ts:1147](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1147) +Defined in: [packages/db/src/types.ts:1233](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1233) **`Internal`** @@ -49,7 +49,7 @@ subscriber or a preload in its current sync run. optional limit: number; ``` -Defined in: [packages/db/src/types.ts:1184](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1184) +Defined in: [packages/db/src/types.ts:1278](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1278) **`Internal`** @@ -67,7 +67,7 @@ Optional limit to include in loadSubset for query-specific cache keys. optional onLoadSubsetError: (event) => void; ``` -Defined in: [packages/db/src/types.ts:1171](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1171) +Defined in: [packages/db/src/types.ts:1263](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1263) **`Internal`** @@ -97,7 +97,7 @@ Omit.onLoadSubsetError optional onLoadSubsetResult: (result) => void; ``` -Defined in: [packages/db/src/types.ts:1169](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1169) +Defined in: [packages/db/src/types.ts:1261](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1261) **`Internal`** @@ -128,7 +128,7 @@ Omit.onLoadSubsetResult optional onStatusChange: (event) => void; ``` -Defined in: [packages/db/src/types.ts:1153](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1153) +Defined in: [packages/db/src/types.ts:1245](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1245) **`Internal`** @@ -159,7 +159,7 @@ Omit.onStatusChange optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:1183](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1183) +Defined in: [packages/db/src/types.ts:1277](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1277) **`Internal`** @@ -171,18 +171,55 @@ Optional orderBy to include in loadSubset for query-specific cache keys. *** +### owner? + +```ts +optional owner: CollectionDependent; +``` + +Defined in: [packages/db/src/types.ts:1239](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1239) + +**`Internal`** + +The live query or Effect this subscription reads for, so +`collection.subscribers` can name it. + +#### Inherited from + +[`SubscribeChangesOptions`](SubscribeChangesOptions.md).[`owner`](SubscribeChangesOptions.md#owner) + +*** + ### truncateReplayPublication? ```ts optional truncateReplayPublication: object; ``` -Defined in: [packages/db/src/types.ts:1173](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1173) +Defined in: [packages/db/src/types.ts:1265](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1265) **`Internal`** Lets a live-query graph retain its last publication during replay. +#### fail()? + +```ts +readonly optional fail: (error) => void; +``` + +The replay could not load every active demand again. + +##### Parameters + +###### error + +`Error` + +##### Returns + +`void` + #### start() ```ts @@ -217,7 +254,7 @@ Omit.truncateReplayPublication optional where: (row) => any; ``` -Defined in: [packages/db/src/types.ts:1136](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1136) +Defined in: [packages/db/src/types.ts:1222](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1222) Callback function for filtering changes using a row proxy. The callback receives a proxy object that records property access, @@ -257,7 +294,7 @@ Omit.where optional whereExpression: BasicExpression<boolean>; ``` -Defined in: [packages/db/src/types.ts:1138](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1138) +Defined in: [packages/db/src/types.ts:1224](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1224) Pre-compiled expression for filtering changes diff --git a/docs/reference/interfaces/SyncConfig.md b/docs/reference/interfaces/SyncConfig.md index c8ed810d0c..ae498badd4 100644 --- a/docs/reference/interfaces/SyncConfig.md +++ b/docs/reference/interfaces/SyncConfig.md @@ -23,7 +23,7 @@ Defined in: [packages/db/src/types.ts:445](https://github.com/TanStack/db/blob/m optional exportSyncMeta: () => unknown; ``` -Defined in: [packages/db/src/types.ts:500](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L500) +Defined in: [packages/db/src/types.ts:515](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L515) Export adapter-specific metadata that lets hydration/persistence resume sync. The payload shape is owned by the adapter. @@ -40,7 +40,7 @@ The payload shape is owned by the adapter. optional getSyncMetadata: () => Record<string, unknown>; ``` -Defined in: [packages/db/src/types.ts:494](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L494) +Defined in: [packages/db/src/types.ts:509](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L509) Get the sync metadata for insert operations @@ -58,7 +58,7 @@ Record containing relation information optional importSyncMeta: (meta) => void; ``` -Defined in: [packages/db/src/types.ts:505](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L505) +Defined in: [packages/db/src/types.ts:520](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L520) Import adapter-specific metadata produced by exportSyncMeta. @@ -80,7 +80,7 @@ Import adapter-specific metadata produced by exportSyncMeta. optional mergeSyncMeta: (current, incoming) => unknown; ``` -Defined in: [packages/db/src/types.ts:510](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L510) +Defined in: [packages/db/src/types.ts:525](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L525) Merge two adapter-specific metadata payloads during hydration. @@ -106,7 +106,7 @@ Merge two adapter-specific metadata payloads during hydration. optional rowUpdateMode: "full" | "partial"; ``` -Defined in: [packages/db/src/types.ts:519](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L519) +Defined in: [packages/db/src/types.ts:534](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L534) The row update mode used to sync to the collection. @@ -159,6 +159,16 @@ optimistic state. A signal that is already aborted abandons the transaction before acceptance, and the receipt rejects with an error named `AbortError`. Aborting after acceptance has no effect. +###### fail + +(`error`) => `void` + +The sync adapter stopped. Before the first ready the status becomes +`error` and `preload()` rejects with `error`. After it, the rows stay, the +status stays `ready`, and the node's `error` holds the cause until a +reload. Throws after an earlier `fail()`. +While the sync adapter retries, call nothing or `reportError()`. + ###### invalidateLoadedSubsets () => `void` @@ -169,23 +179,32 @@ own has applied. Each ordered live query then re-establishes its window with a bounded repair from the start of its order. Calls in one turn are coalesced. Do not call it for results that answer a `loadSubset` call. -###### markError - -(`error?`) => `void` - -Signal that initial sync failed before producing a usable snapshot. -When supplied, `error` is preserved as the rejection reason from `preload()`. - ###### markReady () => `void` -Signal that a usable initial or recovered snapshot is available. +Signal that a usable initial snapshot is available for this attempt. ###### metadata? [`SyncMetadataApi`](SyncMetadataApi.md)\<`TKey`\> +###### reportError + +(`error`) => `void` + +Report a problem after the first ready while the sync adapter keeps +trying. It shows as the node's `failureReason`; the status and rows do +not change. Throws before the first ready and after `fail()`. + +###### reportHealthy + +() => `void` + +Sync works again: clears a `failureReason`. With nothing to clear it does +nothing, so it can follow every commit. Throws before the first ready +and after `fail()`. + ###### truncate (`options?`) => `void` diff --git a/docs/reference/interfaces/SyncMetadataApi.md b/docs/reference/interfaces/SyncMetadataApi.md index 226eb71116..2420f9bd81 100644 --- a/docs/reference/interfaces/SyncMetadataApi.md +++ b/docs/reference/interfaces/SyncMetadataApi.md @@ -3,7 +3,7 @@ id: SyncMetadataApi title: SyncMetadataApi --- -Defined in: [packages/db/src/types.ts:522](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L522) +Defined in: [packages/db/src/types.ts:537](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L537) ## Type Parameters @@ -19,7 +19,7 @@ Defined in: [packages/db/src/types.ts:522](https://github.com/TanStack/db/blob/m collection: object; ``` -Defined in: [packages/db/src/types.ts:530](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L530) +Defined in: [packages/db/src/types.ts:545](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L545) #### delete() @@ -99,7 +99,7 @@ persistence: | null; ``` -Defined in: [packages/db/src/types.ts:548](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L548) +Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L563) **`Internal`** @@ -119,7 +119,7 @@ invalid. row: object; ``` -Defined in: [packages/db/src/types.ts:525](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L525) +Defined in: [packages/db/src/types.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L540) #### delete() diff --git a/docs/reference/powersync-db-collection/classes/PowerSyncTransactor.md b/docs/reference/powersync-db-collection/classes/PowerSyncTransactor.md index 0ecc509271..46c0504013 100644 --- a/docs/reference/powersync-db-collection/classes/PowerSyncTransactor.md +++ b/docs/reference/powersync-db-collection/classes/PowerSyncTransactor.md @@ -111,7 +111,7 @@ Persists a Transaction to the PowerSync SQLite database. protected getMutationCollectionMeta(mutation): PowerSyncCollectionMeta<any>; ``` -Defined in: [PowerSyncTransactor.ts:340](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L340) +Defined in: [PowerSyncTransactor.ts:341](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L341) #### Parameters @@ -134,7 +134,7 @@ protected handleDelete( waitForCompletion): Promise<PendingOperation | null>; ``` -Defined in: [PowerSyncTransactor.ts:258](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L258) +Defined in: [PowerSyncTransactor.ts:259](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L259) #### Parameters @@ -165,7 +165,7 @@ protected handleInsert( waitForCompletion): Promise<PendingOperation | null>; ``` -Defined in: [PowerSyncTransactor.ts:180](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L180) +Defined in: [PowerSyncTransactor.ts:181](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L181) #### Parameters @@ -197,7 +197,7 @@ protected handleOperationWithCompletion( handler): Promise<PendingOperation | null>; ``` -Defined in: [PowerSyncTransactor.ts:301](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L301) +Defined in: [PowerSyncTransactor.ts:302](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L302) Helper function which wraps a persistence operation by: - Fetching the mutation's collection's SQLite table details @@ -237,7 +237,7 @@ protected handleUpdate( waitForCompletion): Promise<PendingOperation | null>; ``` -Defined in: [PowerSyncTransactor.ts:216](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L216) +Defined in: [PowerSyncTransactor.ts:217](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L217) #### Parameters @@ -265,7 +265,7 @@ Defined in: [PowerSyncTransactor.ts:216](https://github.com/TanStack/db/blob/mai protected processMutationMetadata(mutation): string | null; ``` -Defined in: [PowerSyncTransactor.ts:359](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L359) +Defined in: [PowerSyncTransactor.ts:360](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/PowerSyncTransactor.ts#L360) Processes collection mutation metadata for persistence to the database. We only support storing string metadata. diff --git a/docs/reference/query-db-collection/functions/queryCollectionOptions.md b/docs/reference/query-db-collection/functions/queryCollectionOptions.md index 8cc2249e7b..e5cd3437a1 100644 --- a/docs/reference/query-db-collection/functions/queryCollectionOptions.md +++ b/docs/reference/query-db-collection/functions/queryCollectionOptions.md @@ -9,7 +9,7 @@ title: queryCollectionOptions function queryCollectionOptions<T, TQueryFn, TError, TQueryKey, TKey, TQueryData>(config): CollectionConfig<InferSchemaOutput<T>, TKey, T, QueryCollectionUtils<InferSchemaOutput<T>, TKey, InferSchemaInput<T>, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:576](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L576) +Defined in: [packages/query-db-collection/src/query.ts:540](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L540) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -149,7 +149,7 @@ const todosCollection = createCollection( function queryCollectionOptions<T, TQueryFn, TError, TQueryKey, TKey, TQueryData>(config): CollectionConfig<T, TKey, never, QueryCollectionUtils<T, TKey, T, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:615](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L615) +Defined in: [packages/query-db-collection/src/query.ts:579](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L579) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -289,7 +289,7 @@ const todosCollection = createCollection( function queryCollectionOptions<T, TError, TQueryKey, TKey>(config): CollectionConfig<InferSchemaOutput<T>, TKey, T, QueryCollectionUtils<InferSchemaOutput<T>, TKey, InferSchemaInput<T>, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:648](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L648) +Defined in: [packages/query-db-collection/src/query.ts:612](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L612) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -421,7 +421,7 @@ const todosCollection = createCollection( function queryCollectionOptions<T, TError, TQueryKey, TKey>(config): CollectionConfig<T, TKey, never, QueryCollectionUtils<T, TKey, T, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:682](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L682) +Defined in: [packages/query-db-collection/src/query.ts:646](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L646) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. diff --git a/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md b/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md index 5d1f24234d..dc712307a0 100644 --- a/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md +++ b/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md @@ -10,6 +10,10 @@ Direct writes bypass optimistic mutations and write to the synced data store. Direct writes do not trigger a Query refetch. On-demand cache edits are best-effort and do not establish scoped result authority. +## Template + +The type of errors that can occur during queries + ## Type Parameters ### TItem @@ -30,71 +34,31 @@ The type of the item keys The type accepted for insert operations -### TError +### _TError -`TError` = `unknown` - -The type of errors that can occur during queries +`_TError` = `unknown` ## Properties -### clearError() - -```ts -clearError: () => Promise<void>; -``` - -Defined in: [packages/query-db-collection/src/query.ts:357](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L357) - -Refetch, retaining errors until a successful result applies. While a user -mutation is persisting or its handler is active, this retains the Query -fetch boundary so it cannot wait on publication blocked by that -transaction. - -#### Returns - -`Promise`\<`void`\> - -Promise that resolves when the applicable refetch boundary completes - -#### Throws - -Error if the refetch fails - -*** - ### dataUpdatedAt ```ts dataUpdatedAt: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:341](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L341) +Defined in: [packages/query-db-collection/src/query.ts:334](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L334) Get timestamp of last successful data update (in milliseconds) *** -### errorCount - -```ts -errorCount: number; -``` - -Defined in: [packages/query-db-collection/src/query.ts:333](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L333) - -Get the number of consecutive sync failures. -Incremented only when query fails completely (not per retry attempt); reset after a successful result applies. - -*** - ### fetchStatus ```ts fetchStatus: "idle" | "fetching" | "paused"; ``` -Defined in: [packages/query-db-collection/src/query.ts:347](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L347) +Defined in: [packages/query-db-collection/src/query.ts:340](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L340) Get the aggregate observer fetch status. Returns `fetching` if any observer is fetching, otherwise `paused` if any observer is paused, and @@ -102,25 +66,13 @@ observer is fetching, otherwise `paused` if any observer is paused, and *** -### isError - -```ts -isError: boolean; -``` - -Defined in: [packages/query-db-collection/src/query.ts:328](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L328) - -Check if the collection is in an error state - -*** - ### isFetching ```ts isFetching: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:335](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L335) +Defined in: [packages/query-db-collection/src/query.ts:328](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L328) Check if query is currently fetching (initial or background) @@ -132,7 +84,7 @@ Check if query is currently fetching (initial or background) isLoading: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:339](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L339) +Defined in: [packages/query-db-collection/src/query.ts:332](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L332) Check if query is loading for the first time (no data yet) @@ -144,31 +96,19 @@ Check if query is loading for the first time (no data yet) isRefetching: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:337](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L337) +Defined in: [packages/query-db-collection/src/query.ts:330](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L330) Check if query is refetching in background (not initial fetch) *** -### lastError - -```ts -lastError: TError | undefined; -``` - -Defined in: [packages/query-db-collection/src/query.ts:326](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L326) - -Get the last error encountered by the query (if any); reset after a successful result applies - -*** - ### refetch ```ts refetch: RefetchFn; ``` -Defined in: [packages/query-db-collection/src/query.ts:310](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L310) +Defined in: [packages/query-db-collection/src/query.ts:311](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L311) Manually refetch and await the applicable fetch or application boundary. @@ -180,7 +120,7 @@ Manually refetch and await the applicable fetch or application boundary. writeBatch: (callback) => Promise<void>; ``` -Defined in: [packages/query-db-collection/src/query.ts:322](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L322) +Defined in: [packages/query-db-collection/src/query.ts:323](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L323) Execute direct writes as one atomic batch. Resolves when the sync commit applies. @@ -202,7 +142,7 @@ Execute direct writes as one atomic batch. Resolves when the sync commit applies writeDelete: (keys) => Promise<void>; ``` -Defined in: [packages/query-db-collection/src/query.ts:318](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L318) +Defined in: [packages/query-db-collection/src/query.ts:319](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L319) Delete items without an optimistic update. Resolves when the sync commit applies. @@ -224,7 +164,7 @@ Delete items without an optimistic update. Resolves when the sync commit applies writeInsert: (data) => Promise<void>; ``` -Defined in: [packages/query-db-collection/src/query.ts:312](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L312) +Defined in: [packages/query-db-collection/src/query.ts:313](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L313) Insert items without an optimistic update. Resolves when the sync commit applies. @@ -246,7 +186,7 @@ Insert items without an optimistic update. Resolves when the sync commit applies writeUpdate: (updates) => Promise<void>; ``` -Defined in: [packages/query-db-collection/src/query.ts:314](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L314) +Defined in: [packages/query-db-collection/src/query.ts:315](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L315) Update items without an optimistic update. Resolves when the sync commit applies. @@ -268,7 +208,7 @@ Update items without an optimistic update. Resolves when the sync commit applies writeUpsert: (data) => Promise<void>; ``` -Defined in: [packages/query-db-collection/src/query.ts:320](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L320) +Defined in: [packages/query-db-collection/src/query.ts:321](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L321) Insert or update items without an optimistic update. Resolves when the sync commit applies. diff --git a/docs/reference/rxdb-db-collection/functions/rxdbCollectionOptions.md b/docs/reference/rxdb-db-collection/functions/rxdbCollectionOptions.md index 4b0834eda3..22416d572c 100644 --- a/docs/reference/rxdb-db-collection/functions/rxdbCollectionOptions.md +++ b/docs/reference/rxdb-db-collection/functions/rxdbCollectionOptions.md @@ -53,7 +53,7 @@ Creates RxDB collection options for use with a standard Collection #### config -`Omit`\<`BaseCollectionConfig`\<`T`, `string`, `never`, `UtilsRecord`, `any`\>, `"getKey"` \| `"onInsert"` \| `"onUpdate"` \| `"onDelete"`\> & `object` & `object` +`Omit`\<`BaseCollectionConfig`\<`T`, `string`, `never`, `UtilsRecord`, `any`\>, `"onInsert"` \| `"onUpdate"` \| `"onDelete"` \| `"getKey"`\> & `object` & `object` Configuration options for the RxDB collection diff --git a/docs/reference/type-aliases/ChangeListener.md b/docs/reference/type-aliases/ChangeListener.md index e3dac4662e..0ffc44a43c 100644 --- a/docs/reference/type-aliases/ChangeListener.md +++ b/docs/reference/type-aliases/ChangeListener.md @@ -7,7 +7,7 @@ title: ChangeListener type ChangeListener<T, TKey> = (changes) => void; ``` -Defined in: [packages/db/src/types.ts:1231](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1231) +Defined in: [packages/db/src/types.ts:1325](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1325) Function type for listening to collection changes Changes to the same key retain their causal order within a callback. diff --git a/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md b/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md index 0780031d3a..bd6571dac6 100644 --- a/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md +++ b/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md @@ -9,7 +9,7 @@ type ChangeMessageOrDeleteKeyMessage<T, TKey> = | DeleteKeyMessage<TKey>; ``` -Defined in: [packages/db/src/types.ts:616](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L616) +Defined in: [packages/db/src/types.ts:631](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L631) ## Type Parameters diff --git a/docs/reference/type-aliases/ChangesPayload.md b/docs/reference/type-aliases/ChangesPayload.md index 1ce72c6260..f0bc02000c 100644 --- a/docs/reference/type-aliases/ChangesPayload.md +++ b/docs/reference/type-aliases/ChangesPayload.md @@ -7,7 +7,7 @@ title: ChangesPayload type ChangesPayload<T, TKey> = ChangeMessage<WithVirtualProps<T, TKey>, TKey>[]; ``` -Defined in: [packages/db/src/types.ts:1073](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1073) +Defined in: [packages/db/src/types.ts:1138](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1138) ## Type Parameters diff --git a/docs/reference/type-aliases/CollectionBase.md b/docs/reference/type-aliases/CollectionBase.md index 8502afee10..d13846951d 100644 --- a/docs/reference/type-aliases/CollectionBase.md +++ b/docs/reference/type-aliases/CollectionBase.md @@ -14,7 +14,7 @@ type CollectionBase<TKey, TOutput> = Pick<SortedMap<TKey, TOutput>, | typeof Symbol.iterator>; ``` -Defined in: [packages/db/src/collection/index.ts:54](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L54) +Defined in: [packages/db/src/collection/index.ts:60](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts#L60) ## Type Parameters diff --git a/docs/reference/type-aliases/CollectionConfigSingleRowOption.md b/docs/reference/type-aliases/CollectionConfigSingleRowOption.md index a1c12e004e..6d35789039 100644 --- a/docs/reference/type-aliases/CollectionConfigSingleRowOption.md +++ b/docs/reference/type-aliases/CollectionConfigSingleRowOption.md @@ -7,7 +7,7 @@ title: CollectionConfigSingleRowOption type CollectionConfigSingleRowOption<T, TKey, TSchema, TUtils> = CollectionConfig<T, TKey, TSchema, TUtils> & MaybeSingleResult; ``` -Defined in: [packages/db/src/types.ts:1066](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1066) +Defined in: [packages/db/src/types.ts:1131](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1131) ## Type Parameters diff --git a/docs/reference/type-aliases/CollectionStatus.md b/docs/reference/type-aliases/CollectionStatus.md index f71cb52d12..a8f5a0abfd 100644 --- a/docs/reference/type-aliases/CollectionStatus.md +++ b/docs/reference/type-aliases/CollectionStatus.md @@ -4,10 +4,10 @@ title: CollectionStatus --- ```ts -type CollectionStatus = "idle" | "loading" | "ready" | "error" | "cleaned-up"; +type CollectionStatus = "idle" | "loading" | "ready" | "error" | "crashed" | "cleaned-up"; ``` -Defined in: [packages/db/src/types.ts:748](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L748) +Defined in: [packages/db/src/types.ts:764](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L764) Collection status values for lifecycle management @@ -25,6 +25,7 @@ if (collection.status === "loading") { ```ts // Status transitions // idle → loading → ready (when markReady() is called) -// Any active status can transition to → error or cleaned-up -// error → ready after a successful sync recovery +// loading → error when the sync adapter calls fail() before the first ready +// any active status → crashed when local storage or an invariant breaks +// After the first ready, sync problems appear in `health`, not in `status`. ``` diff --git a/docs/reference/type-aliases/DeleteKeyMessage.md b/docs/reference/type-aliases/DeleteKeyMessage.md index 8c2f9aeacd..66236bc3ab 100644 --- a/docs/reference/type-aliases/DeleteKeyMessage.md +++ b/docs/reference/type-aliases/DeleteKeyMessage.md @@ -7,7 +7,7 @@ title: DeleteKeyMessage type DeleteKeyMessage<TKey> = Omit<ChangeMessage<any, TKey>, "value" | "previousValue" | "type"> & object; ``` -Defined in: [packages/db/src/types.ts:611](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L611) +Defined in: [packages/db/src/types.ts:626](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L626) ## Type Declaration diff --git a/docs/reference/type-aliases/DeleteMutationFn.md b/docs/reference/type-aliases/DeleteMutationFn.md index 97fdb55e33..6fc69af53c 100644 --- a/docs/reference/type-aliases/DeleteMutationFn.md +++ b/docs/reference/type-aliases/DeleteMutationFn.md @@ -7,7 +7,7 @@ title: DeleteMutationFn type DeleteMutationFn<T, TKey, TUtils, TReturn> = (params) => Promise<TReturn>; ``` -Defined in: [packages/db/src/types.ts:725](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L725) +Defined in: [packages/db/src/types.ts:740](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L740) ## Type Parameters diff --git a/docs/reference/type-aliases/DeleteMutationFnParams.md b/docs/reference/type-aliases/DeleteMutationFnParams.md index 8ccf71df62..4601e8fc17 100644 --- a/docs/reference/type-aliases/DeleteMutationFnParams.md +++ b/docs/reference/type-aliases/DeleteMutationFnParams.md @@ -7,7 +7,7 @@ title: DeleteMutationFnParams type DeleteMutationFnParams<T, TKey, TUtils> = object; ``` -Defined in: [packages/db/src/types.ts:689](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L689) +Defined in: [packages/db/src/types.ts:704](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L704) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [packages/db/src/types.ts:689](https://github.com/TanStack/db/blob/m collection: Collection<T, TKey, TUtils>; ``` -Defined in: [packages/db/src/types.ts:699](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L699) +Defined in: [packages/db/src/types.ts:714](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L714) *** @@ -41,4 +41,4 @@ Defined in: [packages/db/src/types.ts:699](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations<T, "delete", Collection<T, TKey, TUtils>>; ``` -Defined in: [packages/db/src/types.ts:694](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L694) +Defined in: [packages/db/src/types.ts:709](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L709) diff --git a/docs/reference/type-aliases/DeltaEvent.md b/docs/reference/type-aliases/DeltaEvent.md index 143411e096..726b4e3ab7 100644 --- a/docs/reference/type-aliases/DeltaEvent.md +++ b/docs/reference/type-aliases/DeltaEvent.md @@ -26,7 +26,7 @@ type DeltaEvent<TRow, TKey> = }; ``` -Defined in: [packages/db/src/query/effect.ts:54](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L54) +Defined in: [packages/db/src/query/effect.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L58) Delta event emitted when a row enters, exits, or updates within a query result diff --git a/docs/reference/type-aliases/DeltaType.md b/docs/reference/type-aliases/DeltaType.md index 3525a93a9c..8e7cf85e4b 100644 --- a/docs/reference/type-aliases/DeltaType.md +++ b/docs/reference/type-aliases/DeltaType.md @@ -7,6 +7,6 @@ title: DeltaType type DeltaType = "enter" | "exit" | "update"; ``` -Defined in: [packages/db/src/query/effect.ts:51](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L51) +Defined in: [packages/db/src/query/effect.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L55) Event types for query result deltas diff --git a/docs/reference/type-aliases/EffectQueryInput.md b/docs/reference/type-aliases/EffectQueryInput.md index 1457b6969c..f3107b0d53 100644 --- a/docs/reference/type-aliases/EffectQueryInput.md +++ b/docs/reference/type-aliases/EffectQueryInput.md @@ -9,7 +9,7 @@ type EffectQueryInput<TContext> = | QueryBuilder<TContext>; ``` -Defined in: [packages/db/src/query/effect.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L91) +Defined in: [packages/db/src/query/effect.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/query/effect.ts#L95) Query input - can be a builder function or a prebuilt query diff --git a/docs/reference/type-aliases/InputRow.md b/docs/reference/type-aliases/InputRow.md index 7bfe23b61e..81641d8761 100644 --- a/docs/reference/type-aliases/InputRow.md +++ b/docs/reference/type-aliases/InputRow.md @@ -7,6 +7,6 @@ title: InputRow type InputRow = [unknown, Record<string, unknown>]; ``` -Defined in: [packages/db/src/types.ts:1081](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1081) +Defined in: [packages/db/src/types.ts:1146](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1146) An input row from a collection diff --git a/docs/reference/type-aliases/InsertMutationFn.md b/docs/reference/type-aliases/InsertMutationFn.md index 0e8cc31b84..b9c26ae483 100644 --- a/docs/reference/type-aliases/InsertMutationFn.md +++ b/docs/reference/type-aliases/InsertMutationFn.md @@ -7,7 +7,7 @@ title: InsertMutationFn type InsertMutationFn<T, TKey, TUtils, TReturn> = (params) => Promise<TReturn>; ``` -Defined in: [packages/db/src/types.ts:705](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L705) +Defined in: [packages/db/src/types.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L720) ## Type Parameters diff --git a/docs/reference/type-aliases/InsertMutationFnParams.md b/docs/reference/type-aliases/InsertMutationFnParams.md index 68987e1a8c..0697b5dafd 100644 --- a/docs/reference/type-aliases/InsertMutationFnParams.md +++ b/docs/reference/type-aliases/InsertMutationFnParams.md @@ -7,7 +7,7 @@ title: InsertMutationFnParams type InsertMutationFnParams<T, TKey, TUtils> = object; ``` -Defined in: [packages/db/src/types.ts:677](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L677) +Defined in: [packages/db/src/types.ts:692](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L692) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [packages/db/src/types.ts:677](https://github.com/TanStack/db/blob/m collection: Collection<T, TKey, TUtils>; ``` -Defined in: [packages/db/src/types.ts:687](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L687) +Defined in: [packages/db/src/types.ts:702](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L702) *** @@ -41,4 +41,4 @@ Defined in: [packages/db/src/types.ts:687](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations<T, "insert", Collection<T, TKey, TUtils>>; ``` -Defined in: [packages/db/src/types.ts:682](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L682) +Defined in: [packages/db/src/types.ts:697](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L697) diff --git a/docs/reference/type-aliases/KeyedNamespacedRow.md b/docs/reference/type-aliases/KeyedNamespacedRow.md index d23d8f50b3..8fe8f38c78 100644 --- a/docs/reference/type-aliases/KeyedNamespacedRow.md +++ b/docs/reference/type-aliases/KeyedNamespacedRow.md @@ -7,7 +7,7 @@ title: KeyedNamespacedRow type KeyedNamespacedRow = [unknown, NamespacedRow]; ``` -Defined in: [packages/db/src/types.ts:1104](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1104) +Defined in: [packages/db/src/types.ts:1169](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1169) A keyed namespaced row is a row with a key and a namespaced row This is the main representation of a row in a query pipeline diff --git a/docs/reference/type-aliases/KeyedStream.md b/docs/reference/type-aliases/KeyedStream.md index df84f23ad8..be0becb37e 100644 --- a/docs/reference/type-aliases/KeyedStream.md +++ b/docs/reference/type-aliases/KeyedStream.md @@ -7,7 +7,7 @@ title: KeyedStream type KeyedStream = IStreamBuilder<InputRow>; ``` -Defined in: [packages/db/src/types.ts:1087](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1087) +Defined in: [packages/db/src/types.ts:1152](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1152) A keyed stream is a stream of rows This is used as the inputs from a collection to a query diff --git a/docs/reference/type-aliases/LiveQueryCollectionUtils.md b/docs/reference/type-aliases/LiveQueryCollectionUtils.md index 0c6c5236ea..1791ec9de0 100644 --- a/docs/reference/type-aliases/LiveQueryCollectionUtils.md +++ b/docs/reference/type-aliases/LiveQueryCollectionUtils.md @@ -7,7 +7,7 @@ title: LiveQueryCollectionUtils type LiveQueryCollectionUtils = UtilsRecord & object; ``` -Defined in: [packages/db/src/query/live/collection-config-builder.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/collection-config-builder.ts#L58) +Defined in: [packages/db/src/query/live/collection-config-builder.ts:62](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/collection-config-builder.ts#L62) ## Type Declaration diff --git a/docs/reference/type-aliases/LiveQueryObserverListener.md b/docs/reference/type-aliases/LiveQueryObserverListener.md index ec8666e537..dac2ba9ff4 100644 --- a/docs/reference/type-aliases/LiveQueryObserverListener.md +++ b/docs/reference/type-aliases/LiveQueryObserverListener.md @@ -7,7 +7,7 @@ title: LiveQueryObserverListener type LiveQueryObserverListener<T, TKey> = (changes) => void; ``` -Defined in: [packages/db/src/live-query-observer.ts:105](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L105) +Defined in: [packages/db/src/live-query-observer.ts:71](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L71) Listener payload: changes, `[]` for an internal layout-only publication, or `undefined` for a synthetic status/ready notification. diff --git a/docs/reference/type-aliases/LiveQueryPersistedStatus.md b/docs/reference/type-aliases/LiveQueryPersistedStatus.md deleted file mode 100644 index d688694c7f..0000000000 --- a/docs/reference/type-aliases/LiveQueryPersistedStatus.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -id: LiveQueryPersistedStatus -title: LiveQueryPersistedStatus ---- - -```ts -type LiveQueryPersistedStatus = "unavailable" | "loading" | "ready" | "error"; -``` - -Defined in: [packages/db/src/live-query-observer.ts:15](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L15) diff --git a/docs/reference/type-aliases/LiveQueryWindowCollection.md b/docs/reference/type-aliases/LiveQueryWindowCollection.md index 15f55ce235..2a9cd35d92 100644 --- a/docs/reference/type-aliases/LiveQueryWindowCollection.md +++ b/docs/reference/type-aliases/LiveQueryWindowCollection.md @@ -7,7 +7,7 @@ title: LiveQueryWindowCollection type LiveQueryWindowCollection = Collection<any, any, any> & object; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:139](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L139) +Defined in: [packages/db/src/live-query-window-controller.ts:132](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L132) **`Internal`** diff --git a/docs/reference/type-aliases/LiveQueryWindowInputKind.md b/docs/reference/type-aliases/LiveQueryWindowInputKind.md index 47209e8422..bf52c29b7d 100644 --- a/docs/reference/type-aliases/LiveQueryWindowInputKind.md +++ b/docs/reference/type-aliases/LiveQueryWindowInputKind.md @@ -7,4 +7,4 @@ title: LiveQueryWindowInputKind type LiveQueryWindowInputKind = "collection" | "query"; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:28](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L28) +Defined in: [packages/db/src/live-query-window-controller.ts:21](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L21) diff --git a/docs/reference/type-aliases/MakeOptional.md b/docs/reference/type-aliases/MakeOptional.md index 52ecf1eefb..9269a79e9e 100644 --- a/docs/reference/type-aliases/MakeOptional.md +++ b/docs/reference/type-aliases/MakeOptional.md @@ -7,7 +7,7 @@ title: MakeOptional type MakeOptional<T, K> = Omit<T, K> & Partial<Pick<T, K>>; ``` -Defined in: [packages/db/src/types.ts:1310](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1310) +Defined in: [packages/db/src/types.ts:1404](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1404) ## Type Parameters diff --git a/docs/reference/type-aliases/MaybeSingleResult.md b/docs/reference/type-aliases/MaybeSingleResult.md index 9508dc5fbd..ae404a3c59 100644 --- a/docs/reference/type-aliases/MaybeSingleResult.md +++ b/docs/reference/type-aliases/MaybeSingleResult.md @@ -7,7 +7,7 @@ title: MaybeSingleResult type MaybeSingleResult = object; ``` -Defined in: [packages/db/src/types.ts:1058](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1058) +Defined in: [packages/db/src/types.ts:1123](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1123) ## Properties @@ -17,6 +17,6 @@ Defined in: [packages/db/src/types.ts:1058](https://github.com/TanStack/db/blob/ optional singleResult: true; ``` -Defined in: [packages/db/src/types.ts:1062](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1062) +Defined in: [packages/db/src/types.ts:1127](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1127) If enabled the collection will return a single object instead of an array diff --git a/docs/reference/type-aliases/NamespacedAndKeyedStream.md b/docs/reference/type-aliases/NamespacedAndKeyedStream.md index cd10e175d5..d1f9743c2d 100644 --- a/docs/reference/type-aliases/NamespacedAndKeyedStream.md +++ b/docs/reference/type-aliases/NamespacedAndKeyedStream.md @@ -7,7 +7,7 @@ title: NamespacedAndKeyedStream type NamespacedAndKeyedStream = IStreamBuilder<KeyedNamespacedRow>; ``` -Defined in: [packages/db/src/types.ts:1111](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1111) +Defined in: [packages/db/src/types.ts:1176](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1176) A namespaced and keyed stream is a stream of rows This is used throughout a query pipeline and as the output from a query without diff --git a/docs/reference/type-aliases/NamespacedRow.md b/docs/reference/type-aliases/NamespacedRow.md index 131ac7be6d..aad4ca2455 100644 --- a/docs/reference/type-aliases/NamespacedRow.md +++ b/docs/reference/type-aliases/NamespacedRow.md @@ -7,6 +7,6 @@ title: NamespacedRow type NamespacedRow = Record<string, Record<string, unknown>>; ``` -Defined in: [packages/db/src/types.ts:1098](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1098) +Defined in: [packages/db/src/types.ts:1163](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1163) A namespaced row is a row withing a pipeline that had each table wrapped in its alias diff --git a/docs/reference/type-aliases/NonSingleResult.md b/docs/reference/type-aliases/NonSingleResult.md index 293c5a87f2..76700fe4d7 100644 --- a/docs/reference/type-aliases/NonSingleResult.md +++ b/docs/reference/type-aliases/NonSingleResult.md @@ -7,7 +7,7 @@ title: NonSingleResult type NonSingleResult = object; ``` -Defined in: [packages/db/src/types.ts:1054](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1054) +Defined in: [packages/db/src/types.ts:1119](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1119) ## Properties @@ -17,4 +17,4 @@ Defined in: [packages/db/src/types.ts:1054](https://github.com/TanStack/db/blob/ optional singleResult: never; ``` -Defined in: [packages/db/src/types.ts:1055](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1055) +Defined in: [packages/db/src/types.ts:1120](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1120) diff --git a/docs/reference/type-aliases/OptimisticChangeMessage.md b/docs/reference/type-aliases/OptimisticChangeMessage.md index 3dfdea5c72..43fa914738 100644 --- a/docs/reference/type-aliases/OptimisticChangeMessage.md +++ b/docs/reference/type-aliases/OptimisticChangeMessage.md @@ -9,7 +9,7 @@ type OptimisticChangeMessage<T, TKey> = | DeleteKeyMessage<TKey> & object; ``` -Defined in: [packages/db/src/types.ts:621](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L621) +Defined in: [packages/db/src/types.ts:636](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L636) ## Type Parameters diff --git a/docs/reference/type-aliases/PersistedReadinessSnapshot.md b/docs/reference/type-aliases/PersistedReadinessSnapshot.md deleted file mode 100644 index ab8ec1d61a..0000000000 --- a/docs/reference/type-aliases/PersistedReadinessSnapshot.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: PersistedReadinessSnapshot -title: PersistedReadinessSnapshot ---- - -```ts -type PersistedReadinessSnapshot = - | { - error?: never; - status: "loading"; -} - | { - error?: never; - status: "ready"; -} - | { - error: unknown; - status: "error"; -}; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:2](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L2) - -The local-restore boundary of one opted-in persisted Collection. diff --git a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md index 97f3750609..93ab4d6be3 100644 --- a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md +++ b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md @@ -15,7 +15,7 @@ type ResolvedLiveQueryWindowInput<TContext> = }; ``` -Defined in: [packages/db/src/live-query-window-controller.ts:31](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L31) +Defined in: [packages/db/src/live-query-window-controller.ts:24](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L24) **`Internal`** diff --git a/docs/reference/type-aliases/ResultStream.md b/docs/reference/type-aliases/ResultStream.md index 3ba095598b..4b899b6ee9 100644 --- a/docs/reference/type-aliases/ResultStream.md +++ b/docs/reference/type-aliases/ResultStream.md @@ -7,7 +7,7 @@ title: ResultStream type ResultStream = IStreamBuilder<[unknown, [any, string | undefined]]>; ``` -Defined in: [packages/db/src/types.ts:1093](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1093) +Defined in: [packages/db/src/types.ts:1158](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1158) Result stream type representing the output of compiled queries Always returns [key, [result, orderByIndex]] where orderByIndex is undefined for unordered queries diff --git a/docs/reference/type-aliases/SingleResult.md b/docs/reference/type-aliases/SingleResult.md index a1c51c60bf..34c069fba5 100644 --- a/docs/reference/type-aliases/SingleResult.md +++ b/docs/reference/type-aliases/SingleResult.md @@ -7,7 +7,7 @@ title: SingleResult type SingleResult = object; ``` -Defined in: [packages/db/src/types.ts:1050](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1050) +Defined in: [packages/db/src/types.ts:1115](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1115) ## Properties @@ -17,4 +17,4 @@ Defined in: [packages/db/src/types.ts:1050](https://github.com/TanStack/db/blob/ singleResult: true; ``` -Defined in: [packages/db/src/types.ts:1051](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1051) +Defined in: [packages/db/src/types.ts:1116](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1116) diff --git a/docs/reference/type-aliases/SourceClauseContext.md b/docs/reference/type-aliases/SourceClauseContext.md index d1d13a0415..50d6aa70da 100644 --- a/docs/reference/type-aliases/SourceClauseContext.md +++ b/docs/reference/type-aliases/SourceClauseContext.md @@ -7,4 +7,4 @@ title: SourceClauseContext type SourceClauseContext = "from clause" | "unionAll clause" | "join clause"; ``` -Defined in: [packages/db/src/errors.ts:613](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L613) +Defined in: [packages/db/src/errors.ts:643](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L643) diff --git a/docs/reference/type-aliases/StandardSchema.md b/docs/reference/type-aliases/StandardSchema.md index d909673057..7bf6820857 100644 --- a/docs/reference/type-aliases/StandardSchema.md +++ b/docs/reference/type-aliases/StandardSchema.md @@ -7,7 +7,7 @@ title: StandardSchema type StandardSchema<T> = StandardSchemaV1 & object; ``` -Defined in: [packages/db/src/types.ts:638](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L638) +Defined in: [packages/db/src/types.ts:653](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L653) The Standard Schema interface. This follows the standard-schema specification: https://github.com/standard-schema/standard-schema diff --git a/docs/reference/type-aliases/StandardSchemaAlias.md b/docs/reference/type-aliases/StandardSchemaAlias.md index e2d0ef4c6c..7a58ddef23 100644 --- a/docs/reference/type-aliases/StandardSchemaAlias.md +++ b/docs/reference/type-aliases/StandardSchemaAlias.md @@ -7,7 +7,7 @@ title: StandardSchemaAlias type StandardSchemaAlias<T> = StandardSchema<T>; ``` -Defined in: [packages/db/src/types.ts:650](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L650) +Defined in: [packages/db/src/types.ts:665](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L665) Type alias for StandardSchema diff --git a/docs/reference/type-aliases/SyncMode.md b/docs/reference/type-aliases/SyncMode.md index de81065dbd..c67f353030 100644 --- a/docs/reference/type-aliases/SyncMode.md +++ b/docs/reference/type-aliases/SyncMode.md @@ -7,4 +7,4 @@ title: SyncMode type SyncMode = "eager" | "on-demand"; ``` -Defined in: [packages/db/src/types.ts:760](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L760) +Defined in: [packages/db/src/types.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L782) diff --git a/docs/reference/type-aliases/SyncPersistenceCapabilityV1.md b/docs/reference/type-aliases/SyncPersistenceCapabilityV1.md index f8a02b431e..1033c920bd 100644 --- a/docs/reference/type-aliases/SyncPersistenceCapabilityV1.md +++ b/docs/reference/type-aliases/SyncPersistenceCapabilityV1.md @@ -7,7 +7,7 @@ title: SyncPersistenceCapabilityV1 type SyncPersistenceCapabilityV1<TKey> = object; ``` -Defined in: [packages/db/src/types.ts:570](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L570) +Defined in: [packages/db/src/types.ts:585](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L585) **`Internal`** @@ -27,7 +27,7 @@ Unstable cross-package protocol for persistence-aware adapters. readonly hydrateBaseline: () => Promise<void>; ``` -Defined in: [packages/db/src/types.ts:579](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L579) +Defined in: [packages/db/src/types.ts:594](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L594) #### Returns @@ -41,7 +41,7 @@ Defined in: [packages/db/src/types.ts:579](https://github.com/TanStack/db/blob/m readonly optional managedCacheGeneration: boolean; ``` -Defined in: [packages/db/src/types.ts:576](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L576) +Defined in: [packages/db/src/types.ts:591](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L591) This run owns a claimed on-demand cache generation. @@ -53,7 +53,7 @@ This run owns a claimed on-demand cache generation. readonly protocol: "@tanstack/db/sync-persistence"; ``` -Defined in: [packages/db/src/types.ts:573](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L573) +Defined in: [packages/db/src/types.ts:588](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L588) *** @@ -63,7 +63,7 @@ Defined in: [packages/db/src/types.ts:573](https://github.com/TanStack/db/blob/m readonly reserveCommitTurn: () => void; ``` -Defined in: [packages/db/src/types.ts:589](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L589) +Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L604) Reserve the open sync transaction's FIFO turn, so a subset hydration that starts before it commits waits for that commit. @@ -80,7 +80,7 @@ that starts before it commits waits for that commit. readonly resumeSnapshot: object; ``` -Defined in: [packages/db/src/types.ts:593](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L593) +Defined in: [packages/db/src/types.ts:608](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L608) #### certify() @@ -123,7 +123,7 @@ readonly getKeySetEvidence: () => readonly scanPersistedRows: (options?) => Promise<SyncPersistenceScannedRow<TKey>[]>; ``` -Defined in: [packages/db/src/types.ts:590](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L590) +Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) #### Parameters @@ -143,7 +143,7 @@ Defined in: [packages/db/src/types.ts:590](https://github.com/TanStack/db/blob/m readonly optional startScopedRecovery: (resetMetadata?) => Promise<void>; ``` -Defined in: [packages/db/src/types.ts:581](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L581) +Defined in: [packages/db/src/types.ts:596](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L596) Retire untrusted on-demand rows and retain the provider's reset metadata. @@ -171,7 +171,7 @@ Retire untrusted on-demand rows and retain the provider's reset metadata. readonly optional startupCacheGenerationRotated: boolean; ``` -Defined in: [packages/db/src/types.ts:578](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L578) +Defined in: [packages/db/src/types.ts:593](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L593) The on-demand cache generation rotated before the source sync run entered. @@ -183,4 +183,4 @@ The on-demand cache generation rotated before the source sync run entered. readonly version: 1; ``` -Defined in: [packages/db/src/types.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L574) +Defined in: [packages/db/src/types.ts:589](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L589) diff --git a/docs/reference/type-aliases/SyncPersistenceKeySetEvidence.md b/docs/reference/type-aliases/SyncPersistenceKeySetEvidence.md index e06541f940..b45e24b0dd 100644 --- a/docs/reference/type-aliases/SyncPersistenceKeySetEvidence.md +++ b/docs/reference/type-aliases/SyncPersistenceKeySetEvidence.md @@ -7,7 +7,7 @@ title: SyncPersistenceKeySetEvidence type SyncPersistenceKeySetEvidence = object; ``` -Defined in: [packages/db/src/types.ts:551](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L551) +Defined in: [packages/db/src/types.ts:566](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L566) ## Properties @@ -17,4 +17,4 @@ Defined in: [packages/db/src/types.ts:551](https://github.com/TanStack/db/blob/m status: "unknown" | "consistent" | "incompatible"; ``` -Defined in: [packages/db/src/types.ts:552](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L552) +Defined in: [packages/db/src/types.ts:567](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L567) diff --git a/docs/reference/type-aliases/SyncPersistenceScanOptions.md b/docs/reference/type-aliases/SyncPersistenceScanOptions.md index 33c026beb1..315dbac0b9 100644 --- a/docs/reference/type-aliases/SyncPersistenceScanOptions.md +++ b/docs/reference/type-aliases/SyncPersistenceScanOptions.md @@ -7,7 +7,7 @@ title: SyncPersistenceScanOptions type SyncPersistenceScanOptions = object; ``` -Defined in: [packages/db/src/types.ts:555](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L555) +Defined in: [packages/db/src/types.ts:570](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L570) ## Properties @@ -17,4 +17,4 @@ Defined in: [packages/db/src/types.ts:555](https://github.com/TanStack/db/blob/m optional metadataOnly: boolean; ``` -Defined in: [packages/db/src/types.ts:556](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L556) +Defined in: [packages/db/src/types.ts:571](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L571) diff --git a/docs/reference/type-aliases/SyncPersistenceScannedRow.md b/docs/reference/type-aliases/SyncPersistenceScannedRow.md index e7de2b322d..b70053e3ae 100644 --- a/docs/reference/type-aliases/SyncPersistenceScannedRow.md +++ b/docs/reference/type-aliases/SyncPersistenceScannedRow.md @@ -7,7 +7,7 @@ title: SyncPersistenceScannedRow type SyncPersistenceScannedRow<TKey> = object; ``` -Defined in: [packages/db/src/types.ts:559](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L559) +Defined in: [packages/db/src/types.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L574) ## Type Parameters @@ -23,7 +23,7 @@ Defined in: [packages/db/src/types.ts:559](https://github.com/TanStack/db/blob/m key: TKey; ``` -Defined in: [packages/db/src/types.ts:562](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L562) +Defined in: [packages/db/src/types.ts:577](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L577) *** @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:562](https://github.com/TanStack/db/blob/m optional metadata: unknown; ``` -Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L564) +Defined in: [packages/db/src/types.ts:579](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L579) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/m value: object; ``` -Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L563) +Defined in: [packages/db/src/types.ts:578](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L578) diff --git a/docs/reference/type-aliases/UpdateMutationFn.md b/docs/reference/type-aliases/UpdateMutationFn.md index 1f8fc13ed8..79b5d5a7ec 100644 --- a/docs/reference/type-aliases/UpdateMutationFn.md +++ b/docs/reference/type-aliases/UpdateMutationFn.md @@ -7,7 +7,7 @@ title: UpdateMutationFn type UpdateMutationFn<T, TKey, TUtils, TReturn> = (params) => Promise<TReturn>; ``` -Defined in: [packages/db/src/types.ts:715](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L715) +Defined in: [packages/db/src/types.ts:730](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L730) ## Type Parameters diff --git a/docs/reference/type-aliases/UpdateMutationFnParams.md b/docs/reference/type-aliases/UpdateMutationFnParams.md index 4cf26a00ae..bc36e75f06 100644 --- a/docs/reference/type-aliases/UpdateMutationFnParams.md +++ b/docs/reference/type-aliases/UpdateMutationFnParams.md @@ -7,7 +7,7 @@ title: UpdateMutationFnParams type UpdateMutationFnParams<T, TKey, TUtils> = object; ``` -Defined in: [packages/db/src/types.ts:664](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L664) +Defined in: [packages/db/src/types.ts:679](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L679) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [packages/db/src/types.ts:664](https://github.com/TanStack/db/blob/m collection: Collection<T, TKey, TUtils>; ``` -Defined in: [packages/db/src/types.ts:674](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L674) +Defined in: [packages/db/src/types.ts:689](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L689) *** @@ -41,4 +41,4 @@ Defined in: [packages/db/src/types.ts:674](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations<T, "update", Collection<T, TKey, TUtils>>; ``` -Defined in: [packages/db/src/types.ts:669](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L669) +Defined in: [packages/db/src/types.ts:684](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L684) diff --git a/docs/reference/type-aliases/WritableDeep.md b/docs/reference/type-aliases/WritableDeep.md index 3dfd9b0505..31a1d4145c 100644 --- a/docs/reference/type-aliases/WritableDeep.md +++ b/docs/reference/type-aliases/WritableDeep.md @@ -7,7 +7,7 @@ title: WritableDeep type WritableDeep<T> = T extends BuiltIns ? T : T extends (...arguments_) => unknown ? object extends WritableObjectDeep<T> ? T : HasMultipleCallSignatures<T> extends true ? T : (...arguments_) => ReturnType<T> & WritableObjectDeep<T> : T extends ReadonlyMap<unknown, unknown> ? WritableMapDeep<T> : T extends ReadonlySet<unknown> ? WritableSetDeep<T> : T extends ReadonlyArray<unknown> ? WritableArrayDeep<T> : T extends object ? WritableObjectDeep<T> : unknown; ``` -Defined in: [packages/db/src/types.ts:1291](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1291) +Defined in: [packages/db/src/types.ts:1385](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1385) ## Type Parameters diff --git a/docs/reference/variables/PERSISTED_READINESS.md b/docs/reference/variables/PERSISTED_READINESS.md deleted file mode 100644 index 9867fc89cb..0000000000 --- a/docs/reference/variables/PERSISTED_READINESS.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -id: PERSISTED_READINESS -title: PERSISTED_READINESS ---- - -```ts -const PERSISTED_READINESS: typeof PERSISTED_READINESS; -``` - -Defined in: [packages/db/src/persisted-readiness.ts:18](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L18) - -**`Internal`** - -Config capability shared with first-party persistence adapters. diff --git a/packages/angular-db/skills/angular-db/SKILL.md b/packages/angular-db/skills/angular-db/SKILL.md index a06ae03d9c..6d73c60991 100644 --- a/packages/angular-db/skills/angular-db/SKILL.md +++ b/packages/angular-db/skills/angular-db/SKILL.md @@ -67,7 +67,7 @@ const query = injectLiveQuery((q) => q.from({ todo: todoCollection })) // query.data() → Array<T> // query.status() → CollectionStatus | 'disabled' // query.isLoading(), query.isReady(), query.isError() (query must restart) -// query.errors() → error tree; errors()?.hasErrors means data may be stale +// query.health() → health tree; health()?.hasErrors means data may be stale // query.isIdle(), query.isCleanedUp() (seldom used) // query.state() → Map<TKey, T> // query.collection() → Collection | null diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index f3bda2cbca..2cf2bc0b41 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -18,8 +18,8 @@ import type { Collection, CollectionStatus, Context, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -54,10 +54,10 @@ export interface InjectLiveQueryFlags { isCrashed: Signal<boolean> /** A signal indicating whether the collection has been cleaned up */ isCleanedUp: Signal<boolean> - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: Signal<boolean> - /** A signal containing the error tree for the query and its dependencies */ - errors: Signal<ErrorTreeNode | undefined> + /** A signal containing the health tree for the query and its dependencies */ + health: Signal<HealthNode | undefined> } export interface InjectLiveQueryResult< @@ -217,7 +217,7 @@ export function injectLiveQuery(opts: any) { const value = statusValue() return value === `idle` && !collection() ? `disabled` : value }) - const errors = signal<ErrorTreeNode | undefined>(undefined) + const health = signal<HealthNode | undefined>(undefined) // Returns single item for singleResult collections, array otherwise const data = computed(() => { @@ -241,7 +241,7 @@ export function injectLiveQuery(opts: any) { internalData.set(newData) const snapshot = observer.getSnapshot() statusValue.set(snapshot.status) - errors.set(snapshot.errors) + health.set(snapshot.health) } let unsub: (() => void) | null = null @@ -256,7 +256,7 @@ export function injectLiveQuery(opts: any) { // Handle null collection (disabled query) if (!currentCollection) { statusValue.set(`disabled` as const) - errors.set(undefined) + health.set(undefined) state.set(new Map()) internalData.set([]) cleanup() @@ -306,8 +306,8 @@ export function injectLiveQuery(opts: any) { isCrashed: computed(() => status() === `crashed`), isCleanedUp: computed(() => status() === `cleaned-up`), isSyncError: computed( - () => status() === `ready` && errors()?.hasErrors === true, + () => status() === `ready` && health()?.hasErrors === true, ), - errors, + health, } } diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 6623eef7ea..44c9dfef55 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -145,7 +145,7 @@ function makeHandle(result: any, destroy: () => void): LiveQueryHandle { isSyncError: result.isSyncError(), // angular-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result.status() !== `disabled`, - errors: result.errors(), + health: result.health(), }) }, flush: settle, @@ -380,7 +380,7 @@ it(`preserves raw result types through the actual driver reader`, () => { isCrashed: false, isSyncError: false, isEnabled: false, - errors: undefined, + health: undefined, } const result = Object.fromEntries( Object.keys(raw).map((key) => [key, () => raw[key]]), diff --git a/packages/db-sqlite-persistence-core/README.md b/packages/db-sqlite-persistence-core/README.md index e93fca774a..f877c11746 100644 --- a/packages/db-sqlite-persistence-core/README.md +++ b/packages/db-sqlite-persistence-core/README.md @@ -115,7 +115,7 @@ follow it. - An empty restore keeps the Collection `loading` until the network answers. - A sync adapter answer that arrives before the restore completes waits for it. A remote failure then opens the Collection from restored rows and shows - as a sync error on `collection.errors`; without rows, the first load fails. + as a sync error on `collection.health`; without rows, the first load fails. - A failed restore crashes the Collection. - The deadline never cancels network sync. diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index bc7c1fcb3a..d1568c2672 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -6192,7 +6192,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { giveUp!(gaveUp) await flushAsyncWork() expect(collection.status).toBe(`ready`) - expect(collection.errors.error?.cause).toBe(gaveUp) + expect(collection.health.error?.cause).toBe(gaveUp) } collection._sync.unloadSubset(failing) collection._sync.unloadSubset(sibling) @@ -23808,12 +23808,12 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // session's problem. oldSource.reportError(new Error(`retired session`)) await flushAsyncWork() - expect(collection.errors.failureReason).toBeNull() + expect(collection.health.failureReason).toBeNull() sourceSessions[1]!.reportError(new Error(`live session`)) await flushAsyncWork() oldSource.reportHealthy() await flushAsyncWork() - expect(collection.errors.failureReason?.message).toBe(`live session`) + expect(collection.health.failureReason?.message).toBe(`live session`) } finally { releaseOldCallback.resolve() await Promise.allSettled([lateCallback]) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts index 28ea85db45..6003572107 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts @@ -256,8 +256,8 @@ function networkFirst(id: string, initialRender = true) { function observe(collection: ReturnType<typeof networkFirst>[`collection`]) { return { status: collection.status, - error: collection.errors.error?.message ?? null, - failureReason: collection.errors.failureReason?.message ?? null, + error: collection.health.error?.message ?? null, + failureReason: collection.health.failureReason?.message ?? null, rows: collection.status === `ready` ? collection.toArray.map((row) => row.id).sort() @@ -335,7 +335,7 @@ describe(`persisted readiness oracle`, () => { fixture.load.resolve([]) await vi.advanceTimersByTimeAsync(0) expect(collection.status).toBe(`crashed`) - expect(collection.errors.error?.message).toMatch(/reportError/) + expect(collection.health.error?.message).toMatch(/reportError/) } finally { consoleError.mockRestore() await collection.cleanup({ dependents: true }) diff --git a/packages/db/e2e/indexed-db/browser.ts b/packages/db/e2e/indexed-db/browser.ts index c142ed49d6..fea6df599f 100644 --- a/packages/db/e2e/indexed-db/browser.ts +++ b/packages/db/e2e/indexed-db/browser.ts @@ -263,7 +263,7 @@ async function setup() { tab, rows: snapshot(collection.values()), status: collection.status, - syncError: collection.errors.error?.message ?? null, + syncError: collection.health.error?.message ?? null, callbacks: { ...callbacks }, reads: [...reads], writes: [...nativeWrites], diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 8fa5090e93..7c7a279a3f 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -398,12 +398,10 @@ "latchReady": "gO", "reportedTree": "gQ", "endedProblem": "gS", - "errorTree": "gT", "assertAdapterActive": "bI", "statusError": "cI", "stoppedError": "eN", "statusAllowsError": "gI", - "reportErrors": "gK", "enterErrorState": "gM", "crashQuery": "gN", "failQuery": "gW", @@ -427,12 +425,14 @@ "loadOrder": "ha", "cancelSyncTransaction": "g7", "startedBeforeFailure": "hb", - "publishedErrors": "hc", "views": "he", "isDeferredSyncKey": "b6", "overlayHeldAndActive": "e8", "recordOutcome": "gP", "replacements": "dp", "onLoaded": "gR", - "reloadReplay": "gU" + "reloadReplay": "gU", + "publishedHealth": "gK", + "reportHealth": "gT", + "healthTree": "gV" } diff --git a/packages/db/skills/db-core/collection-setup/references/query-adapter.md b/packages/db/skills/db-core/collection-setup/references/query-adapter.md index b686299dce..595d264c3a 100644 --- a/packages/db/skills/db-core/collection-setup/references/query-adapter.md +++ b/packages/db/skills/db-core/collection-setup/references/query-adapter.md @@ -240,7 +240,7 @@ same exact key. Exact-key and prefix invalidation refetch active eager and on-demand queries, then rematerialize their results. Overlapping subsets keep rows that another active subset still owns. A failed refetch retains current rows and records the -error as the source node's `failureReason` in `collection.errors`. Cleaned-up or otherwise inactive queries +error as the source node's `failureReason` in `collection.health`. Cleaned-up or otherwise inactive queries do not rematerialize. ## Predicate Push-Down (syncMode: "on-demand") diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index fa858fac67..f7d8204738 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -360,8 +360,8 @@ After the first `markReady()`, the rows stay. Report a sync problem with when nothing is wrong. When you stop trying, call `fail(error)`: after readiness the node shows an `error`, and only `reload()` clears it. `reportError()` and `reportHealthy()` throw before the first `markReady()`. -Apps read these reports from `collection.errors` and from each live query's -`errors` tree. While a retry is in progress before readiness, report nothing. +Apps read these reports from `collection.health` and from each live query's +`health` tree. While a retry is in progress before readiness, report nothing. A throwing `sync` function crashes the Collection (`status: 'crashed'`). Reserve that for broken local state, not for network failures. diff --git a/packages/db/src/collection/events.ts b/packages/db/src/collection/events.ts index 6154db926b..c31b702701 100644 --- a/packages/db/src/collection/events.ts +++ b/packages/db/src/collection/events.ts @@ -45,12 +45,12 @@ export interface CollectionLoadingSubsetChangeEvent { } /** - * Event emitted when the Collection's error tree may have changed: its own + * Event emitted when the Collection's health tree may have changed: its own * status or problems, or for a live query, those of a Collection it depends - * on. Read `collection.errors` for the current tree. + * on. Read `collection.health` for the current tree. */ -export interface CollectionErrorsChangeEvent { - type: `errors:change` +export interface CollectionHealthChangeEvent { + type: `health:change` collection: Collection<any, any, any, any, any> } @@ -121,7 +121,7 @@ export type AllCollectionEvents = { 'status:change': CollectionStatusChangeEvent 'subscribers:change': CollectionSubscribersChangeEvent 'loadingSubset:change': CollectionLoadingSubsetChangeEvent - 'errors:change': CollectionErrorsChangeEvent + 'health:change': CollectionHealthChangeEvent truncate: CollectionTruncateEvent 'loadedSubsets:invalidated': CollectionLoadedSubsetsInvalidatedEvent 'index:added': CollectionIndexAddedEvent @@ -187,9 +187,9 @@ export class CollectionEventsManager extends EventEmitter<AllCollectionEvents> { ) } - emitErrorsChange() { - this.emit(`errors:change`, { - type: `errors:change`, + emitHealthChange() { + this.emit(`health:change`, { + type: `health:change`, collection: this.collection, }) } diff --git a/packages/db/src/collection/error-tree.ts b/packages/db/src/collection/health-tree.ts similarity index 71% rename from packages/db/src/collection/error-tree.ts rename to packages/db/src/collection/health-tree.ts index b6669bbc04..8c3c4bc13d 100644 --- a/packages/db/src/collection/error-tree.ts +++ b/packages/db/src/collection/health-tree.ts @@ -1,44 +1,44 @@ import { getCollectionBuilder } from '../query/live/collection-registry.js' -import type { ErrorTreeNode } from '../types' +import type { HealthNode } from '../types' import type { CollectionImpl } from './index.js' /** A Collection that another Collection reads, under its query alias. */ -export interface ErrorTreeSource { +export interface HealthSource { readonly alias: string /** Tells apart two sources under one alias, such as two includes. */ readonly id?: string readonly collection: CollectionImpl<any, any, any, any, any> - readonly kind?: ErrorTreeNode[`kind`] + readonly kind?: HealthNode[`kind`] } // One cached node per Collection and source. A node keeps its identity until // its own fields or one of its children change, so a change replaces only the // nodes on its path and readers can compare nodes with `===`. -const nodeCache = new WeakMap<object, Map<string, ErrorTreeNode>>() +const nodeCache = new WeakMap<object, Map<string, HealthNode>>() /** - * Build the error tree for a Collection. A base Collection is one node. A live + * Build the health tree for a Collection. A base Collection is one node. A live * query is a node with one child per source alias, in query order, and each * child that is itself a live query recurses. The tree is read from current * state, so every node mirrors its Collection at the moment it is read. */ -export function buildErrorTree( +export function buildHealthTree( collection: CollectionImpl<any, any, any, any, any>, - { alias, id, kind }: Omit<ErrorTreeSource, `collection`> = { + { alias, id, kind }: Omit<HealthSource, `collection`> = { alias: `root`, }, // The reader that caches this node, so a source's node lives only as long // as the query that reads it. owner: object = collection, -): ErrorTreeNode { +): HealthNode { // A live query's tree shape comes from its query definition. const builder = getCollectionBuilder(collection) - const sources = builder?.errorTreeSources() ?? [] + const sources = builder?.healthSources() ?? [] const { failureReason, error } = collection._lifecycle.problems const children = sources.map((source) => - buildErrorTree(source.collection, source, collection), + buildHealthTree(source.collection, source, collection), ) - return errorTreeNode(owner, id, { + return healthNode(owner, id, { alias, collectionId: collection.id, kind: kind ?? (builder ? `live-query` : `source`), @@ -54,11 +54,11 @@ export function buildErrorTree( * A node from its own facts and its children. It has errors when it or * anything below it has a `failureReason` or an `error`. */ -export function errorTreeNode( +export function healthNode( owner: object, id: string | undefined, - fields: Omit<ErrorTreeNode, `hasErrors`>, -): ErrorTreeNode { + fields: Omit<HealthNode, `hasErrors`>, +): HealthNode { const { failureReason, error, sources } = fields return cachedNode(owner, id ?? fields.alias, { ...fields, @@ -70,22 +70,21 @@ export function errorTreeNode( } /** - * Every node in an error tree that shows a problem of its own, in tree order: + * Every node in a health tree that shows a problem of its own, in tree order: * a `failureReason` or an `error`. A node whose only problems are below it is * not listed; its descendants are. */ -export function findProblems(tree: ErrorTreeNode): Array<ErrorTreeNode> { - if (!tree.hasErrors) return [] - const ownProblem = tree.failureReason !== null || tree.error !== null - return [...(ownProblem ? [tree] : []), ...tree.sources.flatMap(findProblems)] +export function findProblems(health: HealthNode): Array<HealthNode> { + if (!health.hasErrors) return [] + const ownProblem = health.failureReason !== null || health.error !== null + return [ + ...(ownProblem ? [health] : []), + ...health.sources.flatMap(findProblems), + ] } /** Return the cached node for this Collection and alias if nothing changed. */ -function cachedNode( - owner: object, - key: string, - next: ErrorTreeNode, -): ErrorTreeNode { +function cachedNode(owner: object, key: string, next: HealthNode): HealthNode { let nodes = nodeCache.get(owner) if (!nodes) nodeCache.set(owner, (nodes = new Map())) const previous = nodes.get(key) @@ -94,7 +93,7 @@ function cachedNode( return next } -function sameNode(a: ErrorTreeNode, b: ErrorTreeNode): boolean { +function sameNode(a: HealthNode, b: HealthNode): boolean { return ( a.collectionId === b.collectionId && a.kind === b.kind && diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index b3b14fb4de..890f845c3f 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -19,7 +19,7 @@ import { CollectionSyncManager } from './sync' import { CollectionIndexesManager } from './indexes' import { CollectionMutationsManager } from './mutations' import { CollectionEventsManager } from './events.js' -import { buildErrorTree } from './error-tree.js' +import { buildHealthTree } from './health-tree.js' import type { SortedMap } from '../SortedMap.js' import type { PublicationDeferral } from './changes' import type { CollectionSubscription } from './subscription' @@ -36,7 +36,7 @@ import type { CollectionDependent, CollectionStatus, CurrentStateAsChangesOptions, - ErrorTreeNode, + HealthNode, InferSchemaInput, InferSchemaOutput, InsertConfig, @@ -55,7 +55,7 @@ import type { WithVirtualProps } from '../virtual-props.js' import type { TransactionScope } from '../transactions.js' export type { CollectionIndexMetadata } from './events.js' -export { findProblems } from './error-tree.js' +export { findProblems } from './health-tree.js' export type CollectionBase<TKey extends string | number, TOutput> = Pick< SortedMap<TKey, TOutput>, @@ -561,18 +561,18 @@ export class CollectionImpl< } /** - * The error tree for this Collection and every Collection it depends on. + * The health tree for this Collection and every Collection it depends on. * A base Collection's tree is one node. It always has the same shape for a * given query, so apps can parse it. After the first ready, sync problems * appear here and the rows stay. */ - public get errors(): ErrorTreeNode { - return buildErrorTree(this) + public get health(): HealthNode { + return buildHealthTree(this) } - /** @internal A Collection this one depends on changed its error tree. */ - public _emitErrorsChange(): void { - this._events.emitErrorsChange() + /** @internal A Collection this one depends on changed its health tree. */ + public _emitHealthChange(): void { + this._events.emitHealthChange() } /** @@ -1299,7 +1299,7 @@ export class CollectionImpl< /** * Start a new sync run while everything visible stays: the rows, the * status, and the current error, with `reloading` on this Collection's - * error tree node. The new run's first commit replaces the rows at once and + * health node. The new run's first commit replaces the rows at once and * clears the node's problem; a `status` of `error` becomes `ready`. If the * new run fails first, the rows and status stay and the node shows the new * error. On a live query, it reloads each of its sources that has an diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 72eaf63190..e03f9a81be 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -57,7 +57,7 @@ export class CollectionLifecycleManager< private readyFailureSink: ((failure: { error: unknown }) => void) | undefined public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null - // After the first ready, the sync adapter's problem is error tree detail: + // After the first ready, the sync adapter's problem is health tree detail: // `failureReason` while it still tries, `stoppedError` once it called fail(). // A live query's request failures are kept apart, so settling a request // cannot clear the sync adapter's problem. @@ -127,7 +127,7 @@ export class CollectionLifecycleManager< idle: [`loading`, `error`, `crashed`, `cleaned-up`], loading: [`ready`, `error`, `crashed`, `cleaned-up`], // After the first ready only a crash changes the status; a sync - // problem is error tree detail. + // problem is health tree detail. ready: [`cleaned-up`, `crashed`], // A failed first load is final for its sync run. Only a reload leaves // it for `ready` (see `recovering`). @@ -173,9 +173,9 @@ export class CollectionLifecycleManager< // Emit event const isCurrent = () => this.statusRevision === revision this.events.emitStatusChange(newStatus, previousStatus, isCurrent) - // The status is part of the error tree. status:change fires first, and + // The status is part of the health tree. status:change fires first, and // the tree is already current when either event fires. - if (isCurrent()) this.events.emitErrorsChange() + if (isCurrent()) this.events.emitHealthChange() } /** @@ -298,12 +298,12 @@ export class CollectionLifecycleManager< // The rows stay; a reload that failed shows its new error instead. this.failureReason = null this.stoppedError = describeProblem(error) - this.events.emitErrorsChange() + this.events.emitHealthChange() } else if (reload && this.status === `error`) { // A reload of a failed first load keeps status `error` with the new // cause. this.statusError = describeProblem(error) - this.events.emitErrorsChange() + this.events.emitHealthChange() } else { this.validateStatusTransition(this.status, `error`) this.statusError = describeProblem(error) @@ -344,7 +344,7 @@ export class CollectionLifecycleManager< this.reloadRun = run this.failureReasonFromReload = false this.adapterStopped = false - this.events.emitErrorsChange() + this.events.emitHealthChange() return run.promise } @@ -359,7 +359,7 @@ export class CollectionLifecycleManager< if (!this.failureReasonFromReload) this.failureReason = null this.stoppedError = null this.requestErrors = new RequestErrors() - if (this.status === `ready`) this.events.emitErrorsChange() + if (this.status === `ready`) this.events.emitHealthChange() else { // A failed first load, or a reload during the first load, becomes // ready with the new run's rows. @@ -390,7 +390,7 @@ export class CollectionLifecycleManager< /** The reload ended without a commit, for example by cleanup. */ public abandonReload(error: unknown): void { if (!this.endReload(error)) return - if (this.status !== `cleaned-up`) this.events.emitErrorsChange() + if (this.status !== `cleaned-up`) this.events.emitHealthChange() } private endReload(error: unknown): boolean { @@ -418,7 +418,7 @@ export class CollectionLifecycleManager< this.setStatus(`crashed`) } - /** The Collection's own problems, as error tree detail. */ + /** The Collection's own problems, as health tree detail. */ public get problems(): { failureReason: CollectionErrorInfo | null error: CollectionErrorInfo | null @@ -429,7 +429,7 @@ export class CollectionLifecycleManager< return { failureReason: error ? null : this.failureReason, error } } - /** The cause of status `error` or `crashed`, as error tree detail. */ + /** The cause of status `error` or `crashed`, as health tree detail. */ public get statusProblem(): CollectionErrorInfo | null { return this.statusAllowsError() ? this.statusError : null } @@ -445,7 +445,7 @@ export class CollectionLifecycleManager< this.assertReady(`reportError`) this.failureReason = describeProblem(error) this.failureReasonFromReload = this.reloading - this.events.emitErrorsChange() + this.events.emitHealthChange() } /** The source synced successfully again. With nothing to clear it does nothing. */ @@ -455,18 +455,18 @@ export class CollectionLifecycleManager< this.assertReady(`reportHealthy`) if (!this.failureReason) return this.failureReason = null - this.events.emitErrorsChange() + this.events.emitHealthChange() } /** @internal A live query's request failed after initial-query readiness. */ public reportRequestError(key: string, error: unknown): void { this.requestErrors.fail(key, error) - this.events.emitErrorsChange() + this.events.emitHealthChange() } /** @internal The failed request settled or was released. */ public clearRequestError(key: string): void { - if (this.requestErrors.settle(key)) this.events.emitErrorsChange() + if (this.requestErrors.settle(key)) this.events.emitHealthChange() } /** fail() ends the sync adapter's reporting for its sync run. */ @@ -745,7 +745,7 @@ export class CollectionLifecycleManager< (before.error !== after.error || before.failureReason !== after.failureReason) ) - this.events.emitErrorsChange() + this.events.emitHealthChange() // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. this.onFirstReadyCallbacks = [] @@ -836,7 +836,7 @@ export class LoadOrder { } } -/** @internal Error tree detail for a problem value. */ +/** @internal Health tree detail for a problem value. */ export function describeProblem(error: unknown): CollectionErrorInfo { const info = { name: error instanceof Error ? error.name : `Error`, diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 12f7b1d1d1..f4162ea535 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -8,7 +8,7 @@ import { getBuilderFromConfig } from './query/live/collection-registry.js' import { getPooledLiveQuerySource } from './query/pooled-live-query.js' import type { Collection } from './collection/index.js' import type { DbClient, DehydratedLiveQueryResult } from './client.js' -import type { ChangeMessage, CollectionStatus, ErrorTreeNode } from './types.js' +import type { ChangeMessage, CollectionStatus, HealthNode } from './types.js' const noop = () => {} @@ -53,15 +53,15 @@ export interface LiveQuerySnapshot< isEnabled: boolean /** * The data is shown, and something keeps it from staying current: a sync - * problem or a failed request somewhere in `errors`. + * problem or a failed request somewhere in `health`. */ isSyncError: boolean /** - * The error tree for the query and every Collection it depends on, or + * The health tree for the query and every Collection it depends on, or * `undefined` for a disabled query. After the first ready, a source * problem shows here and the data stays. */ - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } /** @@ -146,7 +146,7 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot<any, any> = { isCleanedUp: false, isEnabled: false, isSyncError: false, - errors: undefined, + health: undefined, } class LiveQueryObserverImpl< @@ -163,9 +163,9 @@ class LiveQueryObserverImpl< private cachedCollectionRevision: number | undefined private cachedCollectionLayoutRevision: number | undefined private snapshotDirty = true - // While attached, the tree from the last status or errors change. Every + // While attached, the tree from the last status or health change. Every // tree change is announced, so a snapshot reuses it instead of rebuilding. - private publishedErrors: { tree: ErrorTreeNode | undefined } | undefined + private publishedHealth: { tree: HealthNode | undefined } | undefined private cachedSnapshot: LiveQuerySnapshot<T, TKey> = DISABLED_SNAPSHOT private layoutRevision = 0 private lastLayoutKeys: Array<TKey> | undefined @@ -254,9 +254,9 @@ class LiveQueryObserverImpl< this.layoutRevision++ } - const errors = this.publishedErrors - ? this.publishedErrors.tree - : (collection as { errors?: ErrorTreeNode }).errors + const health = this.publishedHealth + ? this.publishedHealth.tree + : (collection as { health?: HealthNode }).health // Most consumers read only `data`; build the keyed view on first read. let state: Map<TKey, T> | undefined this.cachedSnapshot = { @@ -269,8 +269,8 @@ class LiveQueryObserverImpl< status, ...getLiveQueryStatusFlags(status), isEnabled: true, - isSyncError: status === `ready` && errors?.hasErrors === true, - errors, + isSyncError: status === `ready` && health?.hasErrors === true, + health, } this.snapshotDirty = false } @@ -674,19 +674,19 @@ class LiveQueryObserverImpl< // any status change publishes a synthetic notify so consumers re-read the // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a // detached attachment leaves nothing behind. - // An errors change below the query changes its error tree, not its rows. - // Each status change is followed by an errors change; tree identity - // tells whether the errors change has anything left to publish. - const errorsOf = () => (collection as { errors?: ErrorTreeNode }).errors - this.publishedErrors = { tree: errorsOf() } + // A health change below the query changes its health tree, not its rows. + // Each status change is followed by a health change; tree identity + // tells whether the health change has anything left to publish. + const healthOf = () => (collection as { health?: HealthNode }).health + this.publishedHealth = { tree: healthOf() } const statusUnsub = collection.on(`status:change`, ({ status }) => { - this.publishedErrors = { tree: errorsOf() } + this.publishedHealth = { tree: healthOf() } notify(undefined, status) }) - const errorsUnsub = collection.on(`errors:change`, () => { - const errors = errorsOf() - if (errors === this.publishedErrors?.tree) return - this.publishedErrors = { tree: errors } + const healthUnsub = collection.on(`health:change`, () => { + const health = healthOf() + if (health === this.publishedHealth?.tree) return + this.publishedHealth = { tree: health } // No rows or status changed, so only this marks the snapshot stale. this.snapshotDirty = true notify(undefined, collection.status) @@ -730,10 +730,10 @@ class LiveQueryObserverImpl< }) : noop const release = () => { - this.publishedErrors = undefined + this.publishedHealth = undefined clientUnsub() statusUnsub() - errorsUnsub() + healthUnsub() layoutUnsub() subscription?.unsubscribe() } diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 1a87961fd6..a338199510 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -9,7 +9,7 @@ import { deepEquals } from './utils.js' import { codedMessage, devBuild } from './error-message.js' import type { LiveQueryObserver } from './live-query-observer.js' import type { Collection } from './collection/index.js' -import type { CollectionStatus, ErrorTreeNode } from './types.js' +import type { CollectionStatus, HealthNode } from './types.js' import type { Context, InitialQueryBuilder, @@ -526,10 +526,10 @@ export interface LiveQueryWindowSnapshot< isCrashed: boolean isCleanedUp: boolean isEnabled: boolean - /** The data is shown, and a sync problem or failed page load is in `errors`. */ + /** The data is shown, and a sync problem or failed page load is in `health`. */ isSyncError: boolean - /** The error tree for the collection and its dependencies, or `undefined` when disabled. */ - errors: ErrorTreeNode | undefined + /** The health tree for the collection and its dependencies, or `undefined` when disabled. */ + health: HealthNode | undefined } /** @internal This contract is unstable while RFC #1623 is being implemented. */ @@ -689,7 +689,7 @@ class LiveQueryWindowControllerImpl< isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, isSyncError: observerSnapshot.isSyncError, - errors: observerSnapshot.errors, + health: observerSnapshot.health, } this.cachedFrom = { observerSnapshot, @@ -714,7 +714,7 @@ class LiveQueryWindowControllerImpl< const windowResult = this.ensureLeaseActive(this.committedPageCount) observerUnsub = this.observer.subscribe(() => this.notify()) this.observerUnsub = observerUnsub - // A failed load is a request error in the error tree. + // A failed load is a request error in the health tree. if (windowResult !== true) void windowResult.catch(() => undefined) } catch (error) { observerUnsub?.() diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 005d33aff9..fe5444fa36 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -5,7 +5,7 @@ import { runAllCallbacks } from '../utils/callbacks.js' import { normalizeError } from '../utils/error.js' import { deepEquals } from '../utils.js' import { codedMessage, devBuild } from '../error-message.js' -import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' +import { buildHealthTree, healthNode } from '../collection/health-tree.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' @@ -42,7 +42,7 @@ import type { ChangeMessage, CollectionDependent, CollectionStatus, - ErrorTreeNode, + HealthNode, KeyedStream, StringCollationConfig, } from '../types.js' @@ -146,11 +146,11 @@ export interface EffectConfig< onSourceError?: (error: Error) => void /** - * Called after the effect's initial-query readiness whenever its error tree + * Called after the effect's initial-query readiness whenever its health tree * changes. A sync problem after initial-query readiness does not dispose the - * effect; read the error tree to decide what to do. + * effect; read the health tree to decide what to do. */ - onErrorsChange?: (errors: ErrorTreeNode) => void + onHealthChange?: (health: HealthNode) => void /** * Skip deltas during initial collection load. @@ -337,7 +337,7 @@ export function createEffect< query: config.query, skipInitial: config.skipInitial ?? false, onBatchProcessed, - onErrorsChange: config.onErrorsChange, + onHealthChange: config.onHealthChange, onSourceError: (error: Error) => { if (disposed) return @@ -403,7 +403,7 @@ interface EffectPipelineRunnerConfig< onBatchProcessed: (events: Array<DeltaEvent<TRow, TKey>>) => void /** Called when a source collection enters error or cleaned-up state */ onSourceError: (error: Error) => void - onErrorsChange?: (errors: ErrorTreeNode) => void + onHealthChange?: (health: HealthNode) => void /** The Effect's handle, so its source subscriptions can name it. */ effect: Effect } @@ -493,8 +493,8 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { // initial load has settled, with or without skipInitial. After it, // a sync problem does not dispose the Effect. private reachedReady = false - private reportedTree: ErrorTreeNode | undefined - private readonly onErrorsChange: ((errors: ErrorTreeNode) => void) | undefined + private reportedTree: HealthNode | undefined + private readonly onHealthChange: ((health: HealthNode) => void) | undefined // This Effect as its sources' `subscribers` list it. private readonly dependent: CollectionDependent @@ -516,7 +516,7 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { this.skipInitial = config.skipInitial this.onBatchProcessed = config.onBatchProcessed this.onSourceError = config.onSourceError - this.onErrorsChange = config.onErrorsChange + this.onHealthChange = config.onHealthChange this.dependent = { kind: `effect`, active: true, @@ -802,7 +802,7 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { ) { this.initialLoadComplete = true } - // errors:change follows every status change and reports the tree. + // health:change follows every status change and reports the tree. this.latchReady() } const statusUnsubscribe = collection.on(`status:change`, ({ status }) => @@ -810,7 +810,7 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { ) this.unsubscribeCallbacks.add(statusUnsubscribe) this.unsubscribeCallbacks.add( - collection.on(`errors:change`, () => this.reportErrors()), + collection.on(`health:change`, () => this.reportHealth()), ) const cleanupStartUnsubscribe = collection._onCleanupStart(handleSourceCleanup) @@ -1115,9 +1115,9 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { return this.reachedReady } - /** The error tree for this effect's sources, in query order. */ - private errorTree(): ErrorTreeNode { - return errorTreeNode(this, undefined, { + /** The health tree for this effect's sources, in query order. */ + private healthTree(): HealthNode { + return healthNode(this, undefined, { alias: `root`, collectionId: `effect`, kind: `live-query`, @@ -1126,18 +1126,18 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { error: null, reloading: false, sources: this.collectionSources.map(({ sourceId, alias, collection }) => - buildErrorTree(collection, { alias, id: sourceId }, this), + buildHealthTree(collection, { alias, id: sourceId }, this), ), }) } - private reportErrors(): void { - if (this.disposed || !this.reachedReady || !this.onErrorsChange) return + private reportHealth(): void { + if (this.disposed || !this.reachedReady || !this.onHealthChange) return // An unchanged tree keeps its identity; report each change once. - const tree = this.errorTree() + const tree = this.healthTree() if (tree === this.reportedTree) return this.reportedTree = tree - this.onErrorsChange(tree) + this.onHealthChange(tree) } private canCompleteInitialLoad(): boolean { diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index ac4e3eec35..129cb04525 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1403,7 +1403,7 @@ delivered, so with it the gate also ends initial suppression. An obsolete or aborted initial load releases its hold, and a truncate replacement takes the hold over. Neither can keep the gate open or close it for good. A real load failure before the gate disposes the Effect; after the gate, a source problem -is error tree detail, delivered through `onErrorsChange`. +is health tree detail, delivered through `onHealthChange`. Two limits remain. Before an on-demand source completes a full sync, a ready query can hold rows that the source does not yet update. The source contract, @@ -1678,8 +1678,8 @@ every row before the top-K (a recorded limit). counts as a preload. A deferring live query keeps its sources from garbage collection but is not counted in their `subscriberCount` or `subscribers:change`, which adapters read as a request for live data. -16. **Status and the error tree:** status says whether the data can be - shown; the error tree says whether it is still kept current. A sync run's +16. **Status and the health tree:** status says whether the data can be + shown; the health tree says whether it is still kept current. A sync run's failed first load (`fail()` before Collection readiness) is status `error` and is final for that sync run. A crash (IndexedDB closure, a terminal local persistence failure, a throwing sync function, or an @@ -1716,9 +1716,9 @@ every row before the top-K (a recorded limit). a source whose first load fails before that readiness, or a failed load it needs ends it through `onSourceError`, because its incremental result can no longer be complete. An Effect has no request errors; after that - readiness, a source's sync problem is error tree detail delivered through - `onErrorsChange`, while the Effect keeps running. -17. **Error tree:** a Collection's `errors` is one node; a live query's has + readiness, a source's sync problem is health tree detail delivered through + `onHealthChange`, while the Effect keeps running. +17. **Health tree:** a Collection's `health` is one node; a live query's has one node for itself and one per source alias in query order, recursing through nested live queries. A node shows at most one problem: `failureReason` (the sync adapter still tries) or `error` (the first load @@ -1726,12 +1726,12 @@ every row before the top-K (a recorded limit). query's own node, a request for more data failed). An include's source has `kind: 'include'`; its per-row child Collections are not in the tree, so adding a child changes nothing. Every change to the tree is announced - by `errors:change`, so readers can keep a tree until the next event. A pooled live query's tree is the tree a live-query Collection + by `health:change`, so readers can keep a tree until the next event. A pooled live query's tree is the tree a live-query Collection over its source would have, so it also reaches deeper levels. The shape is fixed by the query. Each node mirrors its Collection when read, and - the query emits `errors:change` whenever a node below it changes. When a + the query emits `health:change` whenever a node below it changes. When a Collection's status changes, its `status:change` fires first and its - `errors:change` follows at once; the tree is current when either fires. + `health:change` follows at once; the tree is current when either fires. A node keeps its object identity until something in its subtree changes, and a change replaces only the nodes on its path. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 138252c66f..4dd3b239bd 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -15,7 +15,7 @@ import { normalizeError } from '../../utils/error.js' import { createSourceRecord } from '../../utils/source-record.js' import { codedMessage, devBuild } from '../../error-message.js' import { collectSourceRefs } from '../ir.js' -import { findProblems } from '../../collection/error-tree.js' +import { findProblems } from '../../collection/health-tree.js' import { CollectionSubscriber } from './collection-subscriber.js' import { getCollectionBuilder } from './collection-registry.js' import { LIVE_QUERY_INTERNAL } from './internal.js' @@ -37,7 +37,7 @@ import type { CollectionSubscription } from '../../collection/subscription.js' import type { RootStreamBuilder } from '@tanstack/db-ivm' import type { OrderByOptimizationInfo } from '../compiler/order-by.js' import type { Collection } from '../../collection/index.js' -import type { ErrorTreeSource } from '../../collection/error-tree.js' +import type { HealthSource } from '../../collection/health-tree.js' import type { CollectionConfigSingleRowOption, CollectionDependent, @@ -150,7 +150,7 @@ export class CollectionConfigBuilder< .filter( (collection, index, all) => all.indexOf(collection) === index && - findProblems(collection.errors).some((node) => node.error !== null), + findProblems(collection.health).some((node) => node.error !== null), ) await Promise.all(failed.map((collection) => collection.reload())) // Its preload is what lets a live query start its sources. @@ -501,8 +501,8 @@ export class CollectionConfigBuilder< return generation } - /** The source aliases of this query, in query order, for its error tree. */ - errorTreeSources(): ReadonlyArray<ErrorTreeSource> { + /** The source aliases of this query, in query order, for its health tree. */ + healthSources(): ReadonlyArray<HealthSource> { return this.collectionSources.map(({ sourceId, alias, collection }) => ({ id: sourceId, alias, @@ -1420,10 +1420,10 @@ export class CollectionConfigBuilder< const statusUnsubscribe = collection.on(`status:change`, (event) => { this.handleSourceStatusChange(config, sourceId, collectionId, event) }) - // Relay error tree changes so this query's tree stays current. + // Relay health tree changes so this query's tree stays current. syncState.unsubscribeCallbacks.add( - collection.on(`errors:change`, () => - this.liveQueryCollection?._emitErrorsChange(), + collection.on(`health:change`, () => + this.liveQueryCollection?._emitHealthChange(), ), ) let cleanupStartError: Error | undefined diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 8deca1e695..d0020bc1ab 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -5,7 +5,7 @@ import { UNSUBSCRIBED_GC_FLOOR_MS, describeProblem, } from '../collection/lifecycle.js' -import { buildErrorTree, errorTreeNode } from '../collection/error-tree.js' +import { buildHealthTree, healthNode } from '../collection/health-tree.js' import { makeComparator } from '../utils/comparison.js' import { isVirtualPropName } from '../virtual-props.js' import { getWhereExpression } from './ir.js' @@ -22,7 +22,7 @@ import type { CollectionDependent, CollectionErrorInfo, CollectionStatus, - ErrorTreeNode, + HealthNode, } from '../types.js' import type { CollectionEventHandler, @@ -554,10 +554,10 @@ class PooledLiveQuery { private endedProblem: CollectionErrorInfo | undefined - /** The same error tree a live-query Collection over this source exposes. */ - get errors(): ErrorTreeNode { + /** The same health tree a live-query Collection over this source exposes. */ + get health(): HealthNode { // Cache keys apart from the alias, which a query may name `root`. - const source = buildErrorTree( + const source = buildHealthTree( this.source, { alias: this.alias, id: `source` }, this, @@ -575,7 +575,7 @@ class PooledLiveQuery { ), )) : null) - return errorTreeNode(this, `view`, { + return healthNode(this, `view`, { alias: `root`, collectionId: this.source.id, kind: `live-query`, diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 3e2ad1df0d..b98679a067 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -759,7 +759,7 @@ export type DeleteMutationFn< * // idle → loading → ready (when markReady() is called) * // loading → error when the sync adapter calls fail() before the first ready * // any active status → crashed when local storage or an invariant breaks - * // After the first ready, sync problems appear in `errors`, not in `status`. + * // After the first ready, sync problems appear in `health`, not in `status`. */ export type CollectionStatus = /** Collection is created but sync hasn't started yet (when startSync config is false) */ @@ -796,7 +796,7 @@ export interface CollectionErrorInfo { } /** - * One node of an error tree. A Collection's tree is one node; a live query's + * One node of a health tree. A Collection's tree is one node; a live query's * has one node for itself and one for each source alias in query order. The * shape is fixed by the query, and nodes without a problem are present too. * @@ -805,7 +805,7 @@ export interface CollectionErrorInfo { * `error`: the first load failed, the sync adapter stopped, the Collection * crashed, or, on a live query's own node, a request for more data failed. */ -export interface ErrorTreeNode { +export interface HealthNode { /** The query alias, or `root` for the Collection the tree was read from. */ readonly alias: string readonly collectionId: string @@ -821,7 +821,7 @@ export interface ErrorTreeNode { * are the previous run's until it commits or fails. */ readonly reloading: boolean - readonly sources: ReadonlyArray<ErrorTreeNode> + readonly sources: ReadonlyArray<HealthNode> } export interface BaseCollectionConfig< diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index c9848c71bf..9f5e5497e5 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -1184,22 +1184,22 @@ describe(`Collection cleanup admission oracle`, () => { expect(live.get(1)?.rank).toBe(1) // After the first ready a source's fail() stops its sync: the - // status and rows stay, and the live query's error tree reports it. + // status and rows stay, and the live query's health tree reports it. sourceSync.fail(new Error(`source sync failed`)) expect(source.status).toBe(`ready`) - expect(source.errors.error).toMatchObject({ + expect(source.health.error).toMatchObject({ message: `source sync failed`, }) expect(live.status).toBe(`ready`) expect(live.get(1)?.rank).toBe(1) - expect(live.errors.sources[0]?.error).toBeTruthy() + expect(live.health.sources[0]?.error).toBeTruthy() // Only a new sync run clears the error. await live.cleanup() await source.cleanup() await live.preload() - expect(source.errors.hasErrors).toBe(false) - expect(live.errors.hasErrors).toBe(false) + expect(source.health.hasErrors).toBe(false) + expect(live.health.hasErrors).toBe(false) expect(starts).toBe(2) expect(source.status).toBe(`ready`) diff --git a/packages/db/tests/conformance-disabled-laws.test.ts b/packages/db/tests/conformance-disabled-laws.test.ts index b5747652ac..7614a13d3b 100644 --- a/packages/db/tests/conformance-disabled-laws.test.ts +++ b/packages/db/tests/conformance-disabled-laws.test.ts @@ -15,7 +15,7 @@ function disabled( isCrashed: false, isSyncError: false, isEnabled: false, - errors: undefined, + health: undefined, } } @@ -52,9 +52,9 @@ const faults: Array<[string, (value: ConformanceResult) => void]> = [ }, ], [ - `stale error tree`, + `stale health tree`, (value) => { - value.errors = { + value.health = { alias: `root`, collectionId: `stale`, kind: `live-query`, diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 4de9da72c7..8786d75ce9 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -22,7 +22,7 @@ * All current scenarios must pass. A future known bug needs an independently * approved exact failure signature; a whole-test waiver could hide another bug. */ -import type { Collection, ErrorTreeNode } from '@tanstack/db' +import type { Collection, HealthNode } from '@tanstack/db' /** Default row shape used by the base scenarios (a "person"). */ export interface Row { @@ -126,14 +126,14 @@ export interface ConformanceResult { isLoadingError: boolean /** Local storage or an invariant broke: `status` is `crashed`. */ isCrashed: boolean - /** The data is shown and the error tree has a problem. */ + /** The data is shown and the health tree has a problem. */ isSyncError: boolean isEnabled: boolean /** - * The hook's error tree, read without normalization so scenarios can judge + * The hook's health tree, read without normalization so scenarios can judge * object identity. `undefined` only for a disabled query. */ - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } /** A query-builder callback, e.g. `(q) => q.from({ items: source.collection })`. */ diff --git a/packages/db/tests/conformance/disabled-laws.ts b/packages/db/tests/conformance/disabled-laws.ts index ff8c386ed1..56ca25d9dc 100644 --- a/packages/db/tests/conformance/disabled-laws.ts +++ b/packages/db/tests/conformance/disabled-laws.ts @@ -11,7 +11,7 @@ export function expectDisabledResult( expect(result.isReady).toBe(true) expect(result.isLoadingError).toBe(false) // A disabled query has no query definition, so it has no aliases to report. - expect(result.errors).toBeUndefined() + expect(result.health).toBeUndefined() if (representation === `absent`) { expect(result.data).toBeUndefined() expect(result.state).toBeUndefined() diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 8a2df7fcd7..4a2df1b756 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -34,7 +34,7 @@ interface InfiniteRow { /** * A fulfilled page fetch advances the committed prefix. A failed request leaves * that prefix and the `ready` status alone; its rejection reaches the caller, - * and a failed source load behind it is a request error in the error tree. + * and a failed source load behind it is a request error in the health tree. * Source length decides whether the committed prefix has a continuation, * including after later source writes. */ diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 3e89505719..b89f42ea98 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1123,13 +1123,13 @@ export function runSuite(rawDriver: LiveQueryDriver) { expect(result.isLoadingError).toBe(kind === `fail`) expect(result.isCrashed).toBe(kind === `crash`) expect(result.isSyncError).toBe(false) - expect(result.errors?.hasErrors).toBe(true) - expect(result.errors?.failureReason).toBeNull() - expect(result.errors?.error).toMatchObject({ + expect(result.health?.hasErrors).toBe(true) + expect(result.health?.failureReason).toBeNull() + expect(result.health?.error).toMatchObject({ message: source.expectedError.message, }) // The binding exposes the exact value the source failed with. - expect(result.errors?.error?.cause).toBe(source.expectedError) + expect(result.health?.error?.cause).toBe(source.expectedError) } h.unmount() }, @@ -1138,7 +1138,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { // ---- sync problems after readiness ------------------------------- // After readiness a sync problem never changes status, rows, or the - // load flags. It appears only in the error tree, on the source alias's + // load flags. It appears only in the health tree, on the source alias's // node, and sets `isSyncError`. `reportError()` sets the node's // `failureReason` and `reportHealthy()` clears it; `fail()` sets its // `error`, which only a new sync run clears, and after it every report @@ -1153,7 +1153,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { // the model. Each step is observed after the driver's `flush` cut. scenario( `sync-errors-after-readiness`, - `sync problems after readiness appear only in the error tree`, + `sync problems after readiness appear only in the health tree`, async () => { type Problem = { field: `failureReason` | `error` @@ -1176,7 +1176,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { const observe = (step: string) => { const result = h.current() - const tree = result.errors + const tree = result.health expect(result.status, step).toBe(`ready`) expect( result.isLoadingError, diff --git a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts index 5a856d39ec..e9d53989fc 100644 --- a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts @@ -228,8 +228,8 @@ it('reports an active receiver abort as the Collection error while its callback // After the first ready a receiver failure stops sync but keeps status. expect(record.statuses).toContain('sync-error') expect(receiver.status).toBe('ready') - expect(receiver.errors.error).toBeTruthy() - expect(receiver.errors.failureReason).toBeNull() + expect(receiver.health.error).toBeTruthy() + expect(receiver.health.failureReason).toBeNull() assertSnapshot( receiver.values(), [], diff --git a/packages/db/tests/indexed-db/recorder.ts b/packages/db/tests/indexed-db/recorder.ts index a122b7a5c3..670c3943fc 100644 --- a/packages/db/tests/indexed-db/recorder.ts +++ b/packages/db/tests/indexed-db/recorder.ts @@ -15,7 +15,7 @@ export type RecordedEvent = { export function recordCollection( collection: Pick< Collection<OracleRow>, - 'subscribeChanges' | 'on' | 'values' | 'status' | 'errors' + 'subscribeChanges' | 'on' | 'values' | 'status' | 'health' >, id: string, sink: (event: RecordedEvent) => void = () => {}, @@ -42,8 +42,8 @@ export function recordCollection( }) // After the first ready, a stopped sync is the Collection's `error`, not a // status. Record it in the status history so a status law still observes it. - const stopErrors = collection.on('errors:change', () => { - if (collection.status === 'ready' && collection.errors.error) + const stopErrors = collection.on('health:change', () => { + if (collection.status === 'ready' && collection.health.error) statuses.push('sync-error') }) const subscription = collection.subscribeChanges( diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index 7d93b71e6e..d35d082cc8 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -882,7 +882,7 @@ it('reports connection closure immediately and supports fresh Collections', asyn // above it, even one that was already ready. Both trees explain why. expect(old.status).toBe('crashed') expect(live.status).toBe('crashed') - expect(live.errors.sources[0]).toMatchObject({ + expect(live.health.sources[0]).toMatchObject({ status: 'crashed', error: expect.anything(), }) @@ -891,7 +891,7 @@ it('reports connection closure immediately and supports fresh Collections', asyn await Channel.deliver() expect(old.status).toBe('crashed') expect(live.status).toBe('crashed') - expect(live.errors.hasErrors).toBe(true) + expect(live.health.hasErrors).toBe(true) expect(fresh.status).toBe('ready') assertRows( fresh.values(), diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 91241e6219..717a455570 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -671,7 +671,7 @@ describe(`createLiveQueryWindowController`, () => { // The failed load is a request error; the window keeps its status. expect(controller.getSnapshot().isLoadingError).toBe(false) expect(controller.getSnapshot().isSyncError).toBe(true) - expect(controller.getSnapshot().errors?.error).toMatchObject({ + expect(controller.getSnapshot().health?.error).toMatchObject({ message: failure.message, }) expect(lq.utils.lastSubsetError).toBe(failure) diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 1a911ee895..7e799d5d49 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -1726,7 +1726,7 @@ async function expectFailedConsolidationKeepsVisibleRows(): Promise<void> { // After the first ready, the failed replacement is a request error on the // live query's node. The query stays ready and keeps established rows. expect(live.status).toBe(`ready`) - expect(live.errors.error).toEqual(expect.anything()) + expect(live.health.error).toEqual(expect.anything()) expect(live.utils.lastSubsetError).toBe(failure) expect(visibleCommentIds()).toEqual([200, 300]) } catch (error) { diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 16cc25d0de..4c09159c74 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -2043,7 +2043,7 @@ describe(`createLiveQueryCollection`, () => { // After the first ready, the failed request is a request error on the // live query's node; the query stays ready. expect(live.status).toBe(`ready`) - expect(live.errors.error).toEqual(expect.anything()) + expect(live.health.error).toEqual(expect.anything()) expect(live.utils.lastSubsetError).toBe(failure) } finally { await Promise.all([live.cleanup(), users.cleanup(), issues.cleanup()]) diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index a4c2d65e05..95d946ae78 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -361,7 +361,7 @@ describe(`loadSubset failure matrix`, () => { // on the live query's node; the query keeps its status and rows. expect(live.status).toBe(`ready`) if (path === `lazy`) { - expect(live.errors).toMatchObject({ + expect(live.health).toMatchObject({ hasErrors: true, failureReason: null, error: expect.anything(), diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index e6ed3962ed..25ced353bd 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1,5 +1,5 @@ /** - * # Source failure and the error tree + * # Source failure and the health tree * * A Collection answers two questions on two separate axes: * @@ -10,7 +10,7 @@ * same sync run is a contract violation that throws. When local storage or * an invariant breaks, the status is `crashed`, from any active status and * for good. Only a new sync run leaves `error`. - * - **The error tree** says whether the data on screen is still kept current. + * - **The health tree** says whether the data on screen is still kept current. * After Collection readiness, `reportError(error)` sets the Collection's * `failureReason` while the sync adapter keeps trying, `reportHealthy()` * clears it, and `fail()` sets its `error`: the sync adapter stopped. The @@ -22,12 +22,12 @@ * sync obligations. * * A live query exposes both axes for everything it depends on through - * `collection.errors`: one node for itself and one per source alias in query + * `collection.health`: one node for itself and one per source alias in query * order, recursing through nested live queries. The tree always has the same * shape for a given query. Each node mirrors its Collection at the moment it - * is read, and the live query emits `errors:change` when a node below it + * is read, and the live query emits `health:change` when a node below it * changes, so a reader knows to read the tree again. When a status changes, - * `status:change` fires first and `errors:change` follows at once, and the + * `status:change` fires first and `health:change` follows at once, and the * tree is current when either fires. A reader can compare trees with `===`: * a node keeps its identity until something in its subtree changes, and a * change replaces only the nodes on its path. @@ -119,14 +119,14 @@ * After every step the driver compares, with the model: the query's status, * each Collection's rows and tags, the baseline a diffing adapter reads, the * query's row count while ready, whether the Effect ended and how often it - * reported a source error, the full projected error tree, and the thrown + * reported a source error, the full projected health tree, and the thrown * error class. An Effect that ends as it is created leaves A's listener count * where it was. It also checks identity: reading twice gives the same tree, * and between steps a node is the same object exactly when its expected * facts, including problem serials, did not change. At every event it checks - * that no `errors:change` shows a status that `status:change` has not + * that no `health:change` shows a status that `status:change` has not * announced, and that each `status:change` is followed at once by - * `errors:change`. When the expected tree changed, the last event must show + * `health:change`. When the expected tree changed, the last event must show * the current tree. `findProblems` must list, in tree order, exactly the * expected nodes with a problem of their own. Request lanes check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's @@ -167,7 +167,7 @@ import { flushPromises } from './utils.js' import type { Collection } from '../src/collection/index.js' import type { CollectionStatus, - ErrorTreeNode, + HealthNode, LoadSubsetOptions, SyncConfig, } from '../src/types.js' @@ -287,7 +287,7 @@ type Model = { // An Effect over A. It reaches its gate when A is ready. A crash of A ends // it at any time, and a failed first load of A ends it before its gate, // including one that A already showed when the Effect was created. After -// its gate, every other problem of A is error tree detail. A cascade cleanup +// its gate, every other problem of A is health tree detail. A cascade cleanup // of A ends it without a source error: the app asked for it. `live`: it has // not ended; `errors`: how often it reported a source error. type EffectModel = { live: boolean; gate: boolean; errors: number } @@ -1042,7 +1042,7 @@ function expectedProblems(node: ExpectedNode): Array<ProblemSummary> { ...node.sources.flatMap(expectedProblems), ] } -function problemOf(node: ErrorTreeNode): ProblemSummary { +function problemOf(node: HealthNode): ProblemSummary { return [ node.alias, (node.error ?? node.failureReason)!.message @@ -1155,7 +1155,7 @@ function createSource(name: SourceName) { // Collection starts with a preload; a pooled view starts when it has a // subscriber, as an observer would subscribe. type Subject = { - query: Pick<Collection<any, any>, `status` | `errors` | `on` | `cleanup`> + query: Pick<Collection<any, any>, `status` | `health` | `on` | `cleanup`> inner?: Collection<any, any> start: () => void size: () => number @@ -1251,8 +1251,8 @@ const cleanupPrefix = /^Collection ".*" sync cleanup function threw an error: / const cleanedUnderQueryMessage = /^Source collection '.*' was manually cleaned up while live query '.*' depends on it\..*$/s -function project(node: ErrorTreeNode): ExpectedNode { - const problem = (info: ErrorTreeNode[`error`]): ExpectedProblem => +function project(node: HealthNode): ExpectedNode { + const problem = (info: HealthNode[`error`]): ExpectedProblem => info ? { message: info.message @@ -1275,8 +1275,8 @@ function project(node: ErrorTreeNode): ExpectedNode { // A node whose expected facts did not change is the same object; a node whose // facts changed is a new object, and so is every node above it. function expectIdentity( - previous: ErrorTreeNode, - next: ErrorTreeNode, + previous: HealthNode, + next: HealthNode, before: ExpectedNode, after: ExpectedNode, label: string, @@ -1452,7 +1452,7 @@ async function runHistory( announced = query.status const record = (kind: Event[`kind`]) => { if (kind === `status`) announced = query.status - const tree = query.errors + const tree = query.health events.push({ kind, announced, @@ -1461,7 +1461,7 @@ async function runHistory( }) } const stopStatus = query.on(`status:change`, () => record(`status`)) - const stopHealth = query.on(`errors:change`, () => record(`health`)) + const stopHealth = query.on(`health:change`, () => record(`health`)) stop = () => { stopStatus() stopHealth() @@ -1476,12 +1476,12 @@ async function runHistory( console.error = () => {} try { expect( - query.errors, - `${shape}: the query exposes an error tree`, + query.health, + `${shape}: the query exposes a health tree`, ).toBeDefined() begin() await flushPromises() - let previous = query.errors + let previous = query.health for (const [index, step] of steps.entries()) { const label = `step ${index} ${JSON.stringify(step)} on ${shape}` const before = expectedTree(shape, model, true) @@ -1683,8 +1683,8 @@ async function runHistory( ).toBe(listenersBeforeEffect) } const tree = expectedTree(shape, model) - const next = query.errors - expect(project(next), `${label}: error tree`).toEqual(tree) + const next = query.health + expect(project(next), `${label}: health tree`).toEqual(tree) expect( findProblems(next).map(problemOf), `${label}: findProblems`, @@ -1693,15 +1693,15 @@ async function runHistory( // Identity: a reader can compare trees with `===`. Reading twice gives // the same tree, and a step replaces exactly the nodes on the path to // what changed. - expect(query.errors === next, `${label}: stable read`).toBe(true) + expect(query.health === next, `${label}: stable read`).toBe(true) const after = expectedTree(shape, model, true) if (step.kind !== `restart`) { expectIdentity(previous, next, before, after, label) } previous = next - // Order: `status:change` comes first and `errors:change` follows it at - // once, so no `errors:change` shows a status that `status:change` has + // Order: `status:change` comes first and `health:change` follows it at + // once, so no `health:change` shows a status that `status:change` has // not announced. The last event leaves a reader with the current tree. if (step.kind !== `restart`) { for (const [at, event] of events.entries()) { @@ -2240,7 +2240,7 @@ describe(`reload invariant`, () => { // The query's own reload waits for its join load: it keeps `error` and // shows `reloading`, and the reload waits too. expect(query.status).toBe(`error`) - expect(query.errors.reloading).toBe(true) + expect(query.health.reloading).toBe(true) expect(settled, `reload settled before the query's run was ready`).toBe( false, ) @@ -2279,7 +2279,7 @@ describe(`reload invariant`, () => { expect(collection.status).toBe(`error`) await expect(collection.reload()).rejects.toThrow(`sync threw`) expect(collection.status).toBe(`crashed`) - expect(collection.errors.reloading).toBe(false) + expect(collection.health.reloading).toBe(false) } finally { console.error = consoleError await collection.cleanup() @@ -2340,7 +2340,7 @@ describe(`reload invariant`, () => { // Cleanup start drops the discarded run's problems while the status waits // for adapter cleanup. Every change to the tree is announced, so the drop - // is too: the last errors:change before the status change shows no + // is too: the last health:change before the status change shows no // problem. The cause of a `crashed` or `error` status stays while that // status shows. it.each([`report`, `fail`, `crash`, `first-load-error`] as const)( @@ -2367,8 +2367,8 @@ describe(`reload invariant`, () => { else if (problem === `fail`) params.fail(new Error(`stopped`)) else if (problem === `crash`) collection._lifecycle.crash(new Error(`crashed`)) - const announced: Array<ErrorTreeNode> = [] - collection.on(`errors:change`, () => announced.push(collection.errors)) + const announced: Array<HealthNode> = [] + collection.on(`health:change`, () => announced.push(collection.health)) const cleaned = collection.cleanup() // A status's own cause stays while that status shows. const keepsCause = problem === `crash` || problem === `first-load-error` @@ -2380,18 +2380,18 @@ describe(`reload invariant`, () => { : `ready`, ) if (keepsCause) { - expect(collection.errors.error?.message).toBe( + expect(collection.health.error?.message).toBe( problem === `crash` ? `crashed` : `first load failed`, ) expect(announced).toEqual([]) } else { - expect(collection.errors.hasErrors).toBe(false) + expect(collection.health.hasErrors).toBe(false) expect(announced.at(-1)?.hasErrors).toBe(false) } adapterCleanup.resolve() await cleaned expect(collection.status).toBe(`cleaned-up`) - expect(collection.errors.hasErrors).toBe(false) + expect(collection.health.hasErrors).toBe(false) }, ) @@ -2433,7 +2433,7 @@ describe(`reload invariant`, () => { ]) expect(settled).toBe(`resolved`) expect(collection.status).toBe(`ready`) - expect(collection.errors.reloading).toBe(false) + expect(collection.health.reloading).toBe(false) await collection.cleanup() }, ) @@ -2513,7 +2513,7 @@ describe(`reload invariant`, () => { expect([...source.collection.keys()].sort()).toEqual([1, 2]) } expect(source.collection.status).toBe(`ready`) - expect(source.collection.errors.error?.message).toBe(`reload failed`) + expect(source.collection.health.error?.message).toBe(`reload failed`) await source.collection.cleanup() }, ) @@ -2545,7 +2545,7 @@ describe(`reload invariant`, () => { next.commit() next.markReady() await reload - expect(source.collection.errors.reloading).toBe(false) + expect(source.collection.health.reloading).toBe(false) expect([...source.collection.keys()]).toEqual([2]) await source.collection.cleanup() }) @@ -2645,8 +2645,8 @@ describe(`request errors`, () => { ) { const history = createRequestHistory() expect( - history.query.errors, - `the query exposes an error tree`, + history.query.health, + `the query exposes a health tree`, ).toBeDefined() const consoleError = console.error console.error = () => {} @@ -2667,23 +2667,23 @@ describe(`request errors`, () => { writeParent(`insert`, 1) markParentsReady() await preload - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) outcomes.push(`fail`) writeParent(`insert`, 2) await flushPromises() expect(query.status).toBe(`ready`) - expect(query.errors.error).toMatchObject({ + expect(query.health.error).toMatchObject({ message: `child load failed`, }) - expect(query.errors.failureReason).toBeNull() - expect(children.errors.hasErrors).toBe(false) + expect(query.health.failureReason).toBeNull() + expect(children.health.hasErrors).toBe(false) // The demand changes again and its new load settles. outcomes.push(`ok`) writeParent(`insert`, 3) await flushPromises() - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) }, )) @@ -2697,12 +2697,12 @@ describe(`request errors`, () => { outcomes.push(`fail`) writeParent(`insert`, 2) await flushPromises() - expect(query.errors.error).toMatchObject({ message: `child load failed` }) + expect(query.health.error).toMatchObject({ message: `child load failed` }) writeParent(`delete`, 1) writeParent(`delete`, 2) await flushPromises() - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) expect(query.status).toBe(`ready`) })) @@ -2730,13 +2730,13 @@ describe(`request errors`, () => { outcomes.push(`fail`) writeParent(`insert`, 2) await flushPromises() - expect(query.errors.error).toMatchObject({ + expect(query.health.error).toMatchObject({ message: `child load failed`, }) await (reloaded === `source` ? children : query).reload() await flushPromises() - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) expect(query.status).toBe(`ready`) expect(query.size).toBe(2) }, @@ -2774,7 +2774,7 @@ describe(`request errors`, () => { held[0]!.resolve() await reload await flushPromises() - expect(query.errors.error, `the later failure stays`).toMatchObject({ + expect(query.health.error, `the later failure stays`).toMatchObject({ message: `child load failed`, }) }, @@ -2789,7 +2789,7 @@ describe(`request errors`, () => { markParentsReady() await flushPromises() expect(query.status).toBe(`error`) - expect(query.errors.error).toMatchObject({ message: `child load failed` }) + expect(query.health.error).toMatchObject({ message: `child load failed` }) await expect(preload).rejects.toBeDefined() })) }) @@ -2835,13 +2835,13 @@ describe(`crash recovery`, () => { expect(runs).toBe(2) expect(collection.status).toBe(`ready`) expect(collection.toArray.map((row) => row.id)).toEqual([2]) - expect(collection.errors.hasErrors).toBe(false) + expect(collection.health.hasErrors).toBe(false) query = createLiveQueryCollection((q) => q.from({ row: collection })) await query.preload() expect(query.status).toBe(`ready`) expect(query.toArray.map((row) => row.id)).toEqual([2]) - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) } finally { console.error = consoleError await query.cleanup() @@ -2893,7 +2893,7 @@ describe(`a query's own failed first load`, () => { await flushPromises() await new Promise((resolve) => setTimeout(resolve, 0)) expect(query.status).toBe(`error`) - expect(query.errors.error).toMatchObject({ message: `child load failed` }) + expect(query.health.error).toMatchObject({ message: `child load failed` }) const loadsAtFailure = loads parents.params.markReady() await flushPromises() @@ -2975,10 +2975,10 @@ describe(`a query's own failed first load`, () => { ? `error` : `crashed`, ) - expect(query.errors.reloading).toBe(false) + expect(query.health.reloading).toBe(false) if (outcome === `ok`) expect(query.size).toBe(1) if (outcome === `load-fails`) - expect(query.errors.error?.message).toContain(`load 2 failed`) + expect(query.health.error?.message).toContain(`load 2 failed`) } finally { console.error = consoleError await query.cleanup() @@ -3068,7 +3068,7 @@ describe(`a failed join load under an ordered filtered window`, () => { Promise.resolve(query.utils.setWindow({ offset: 4, limit: 2 })), ).rejects.toThrow(`marker load failed`) expect(query.status).toBe(`ready`) - expect(query.errors.error).toMatchObject({ + expect(query.health.error).toMatchObject({ message: `marker load failed`, }) @@ -3076,7 +3076,7 @@ describe(`a failed join load under an ordered filtered window`, () => { await query.utils.setWindow({ offset: 6, limit: 2 }) await flushPromises() expect(ids(), `the window moves after the failure`).toEqual([7, 8]) - expect(query.errors.error, `the later load clears it`).toBeNull() + expect(query.health.error, `the later load clears it`).toBeNull() expect(query.status).toBe(`ready`) } finally { console.error = consoleError @@ -3089,7 +3089,7 @@ describe(`a failed join load under an ordered filtered window`, () => { ) }) -describe(`root request errors`, () => { +describe(`root request health`, () => { it(`a failed root load after initial-query readiness is a request error that clears when a later load settles`, async () => { const outcomes: Array<`ok` | `fail`> = [] const source = createCollection<Row, number>({ @@ -3115,12 +3115,12 @@ describe(`root request errors`, () => { .orderBy(({ row }) => row.id) .limit(2), ) - expect(query.errors, `the query exposes an error tree`).toBeDefined() + expect(query.health, `the query exposes a health tree`).toBeDefined() const consoleError = console.error console.error = () => {} try { await query.preload() - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) outcomes.push(`fail`) await Promise.resolve( @@ -3128,12 +3128,12 @@ describe(`root request errors`, () => { ).catch(() => undefined) await flushPromises() expect(query.status).toBe(`ready`) - expect(query.errors.error).toMatchObject({ message: `root load failed` }) - expect(source.errors.hasErrors).toBe(false) + expect(query.health.error).toMatchObject({ message: `root load failed` }) + expect(source.health.hasErrors).toBe(false) await query.utils.setWindow({ offset: 8, limit: 2 }) await flushPromises() - expect(query.errors.hasErrors).toBe(false) + expect(query.health.hasErrors).toBe(false) } finally { console.error = consoleError await query.cleanup() @@ -3228,7 +3228,7 @@ describe(`overlapping root loads`, () => { else loads[load]!.reject(new Error(`load ${load} failed`)) await flushPromises() expect( - query.errors.error?.message ?? null, + query.health.error?.message ?? null, `after load ${load} settles`, ).toBe(model[step]) expect(query.status).toBe(`ready`) @@ -3253,7 +3253,7 @@ describe(`overlapping root loads`, () => { ).toBeGreaterThan(2) expect(started).toBeGreaterThan(2) expect( - query.errors.error?.message ?? null, + query.health.error?.message ?? null, `after a later load succeeds`, ).toBeNull() } @@ -3421,7 +3421,7 @@ describe(`on-demand reload`, () => { expectedOnDemandReload(history, 2), ) expect(query.status).toBe(`ready`) - expect(source.errors.hasErrors).toBe(false) + expect(source.health.hasErrors).toBe(false) } finally { await query.cleanup() await source.cleanup() @@ -3546,7 +3546,7 @@ describe(`on-demand first load after query readiness`, () => { .join({ lazy }, ({ eager, lazy }) => eq(eager.id, lazy.id)), ) const lazyNode = () => - query.errors.sources.find((node) => node.alias === `lazy`)! + query.health.sources.find((node) => node.alias === `lazy`)! const cut = (): LateFirstLoadCut => ({ queryStatus: query.status, rows: query.status === `ready` ? query.size : null, @@ -3574,7 +3574,7 @@ describe(`on-demand first load after query readiness`, () => { await flushPromises() expect(cut(), `after the step`).toEqual(model.afterStep) expect( - findProblems(query.errors).some((node) => node.alias === `lazy`), + findProblems(query.health).some((node) => node.alias === `lazy`), ).toBe(true) if (model.afterReload === `rejects`) { @@ -3584,7 +3584,7 @@ describe(`on-demand first load after query readiness`, () => { await query.reload() await flushPromises() expect(cut(), `after the query's reload`).toEqual(model.afterReload) - expect(findProblems(query.errors)).toEqual([]) + expect(findProblems(query.health)).toEqual([]) } } finally { console.error = consoleError @@ -3607,7 +3607,7 @@ describe(`on-demand first load after query readiness`, () => { * stays healthy. A request error clears only when a load that started after * the failure succeeds; a source's reload loads the window again and clears * it when that replay succeeds. The source's own problems after readiness - * are error tree detail and never change the query's status; its crash + * are health tree detail and never change the query's status; its crash * crashes the query. * * The provider has rows 1 to 99. It answers a request without a limit with @@ -3852,11 +3852,11 @@ async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { expect(query.status, `${label}: query status`).toBe(model.gate) if (model.gate === `ready`) { expect( - query.errors.error?.message ?? null, + query.health.error?.message ?? null, `${label}: request error`, ).toBe(model.requestError) } - const sourceNode = query.errors.sources[0]! + const sourceNode = query.health.sources[0]! if (!model.source.crashed) { expect( sourceNode.failureReason?.message ?? null, @@ -4386,12 +4386,12 @@ async function runLazyHistory( await flushPromises() report() let announcements = 0 - query.on(`errors:change`, () => announcements++) + query.on(`health:change`, () => announcements++) for (const [index, step] of steps.entries()) { const label = `step ${index} ${JSON.stringify(step)}` // A demand change that leaves the projected tree as it was keeps the // tree's identity and announces nothing. - const treeBefore = query.errors + const treeBefore = query.health const factsBefore = JSON.stringify(project(treeBefore)) const announcedBefore = announcements // Only a demand change can leave every fact as it was; a reload, a @@ -4442,13 +4442,13 @@ async function runLazyHistory( deriveLazyGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) - expect(query.errors, `${label}: stable read`).toBe(query.errors) + expect(query.health, `${label}: stable read`).toBe(query.health) if ( demandOnly && !replayBefore && - JSON.stringify(project(query.errors)) === factsBefore + JSON.stringify(project(query.health)) === factsBefore ) { - expect(query.errors, `${label}: unchanged tree keeps identity`).toBe( + expect(query.health, `${label}: unchanged tree keeps identity`).toBe( treeBefore, ) expect( @@ -4464,7 +4464,7 @@ async function runLazyHistory( (model.replayActive || model.replayFailed) && step.kind !== `settle-fail` if (model.gate === `ready` && !replayOpen) { - expect(query.errors.error !== null, `${label}: request error`).toBe( + expect(query.health.error !== null, `${label}: request error`).toBe( model.failed.size > 0 || model.replayFailed, ) } @@ -4749,7 +4749,7 @@ describe(`lazy join demand grammar`, () => { * load after that readiness, because the readiness follows the Effect's * settled loads; like every other source problem after it, that is a sync * problem on the source's node: it does not dispose the Effect, and - * `onErrorsChange` receives the error tree, with one node per source alias. + * `onHealthChange` receives the health tree, with one node per source alias. * * The lane crosses `skipInitial`, the source mode, and when the source fails. * Before initial-query readiness, an eager source has not called `markReady()`, and an @@ -4758,7 +4758,7 @@ describe(`lazy join demand grammar`, () => { * still in its initial sync and sets the `error` of an eager source after * Collection readiness, or it crashes, or it reaches Collection readiness * (on-demand only) and then reports a `failureReason`. Every history also checks that one change reaches - * `onErrorsChange` once: no two reported trees are the same object. + * `onHealthChange` once: no two reported trees are the same object. */ describe(`effect errors`, () => { type EffectHistory = { @@ -4813,7 +4813,7 @@ describe(`effect errors`, () => { }, }) const sourceErrors: Array<Error> = [] - const trees: Array<ErrorTreeNode> = [] + const trees: Array<HealthNode> = [] const delivered: Array<unknown> = [] const effect = createEffect({ query: (q) => q.from({ row: source }), @@ -4822,7 +4822,7 @@ describe(`effect errors`, () => { delivered.push(...events.map((event) => event.key)) }, onSourceError: (error) => sourceErrors.push(error), - onErrorsChange: (errors) => trees.push(errors), + onHealthChange: (health) => trees.push(health), }) const consoleError = console.error console.error = () => {} @@ -4852,7 +4852,7 @@ describe(`effect errors`, () => { if (failure === `report after gate`) { // An on-demand source reaches its own Collection readiness after the - // Effect's: a status change that must reach onErrorsChange once. + // Effect's: a status change that must reach onHealthChange once. if (syncMode === `on-demand`) params.markReady() await flushPromises() params.reportError(new Error(`stalled`)) @@ -4950,7 +4950,7 @@ describe(`effect over a source that already stopped`, () => { ].reduce((sum, set) => sum + set.size, 0) const listenersBefore = listeners() const sourceErrors: Array<Error> = [] - const trees: Array<ErrorTreeNode> = [] + const trees: Array<HealthNode> = [] const consoleError = console.error console.error = () => {} const effect = createEffect({ @@ -4958,7 +4958,7 @@ describe(`effect over a source that already stopped`, () => { skipInitial, onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onErrorsChange: (errors) => trees.push(errors), + onHealthChange: (health) => trees.push(health), }) try { await flushPromises() @@ -5040,7 +5040,7 @@ describe(`effect load failures`, () => { parentParams.commit() } const sourceErrors: Array<Error> = [] - const trees: Array<ErrorTreeNode> = [] + const trees: Array<HealthNode> = [] const effect = createEffect({ query: (q) => q @@ -5051,7 +5051,7 @@ describe(`effect load failures`, () => { skipInitial, onBatch: () => {}, onSourceError: (error) => sourceErrors.push(error), - onErrorsChange: (errors) => trees.push(errors), + onHealthChange: (health) => trees.push(health), }) const consoleError = console.error console.error = () => {} @@ -5083,7 +5083,7 @@ describe(`effect load failures`, () => { * Two includes that read one Collection under the same alias are two include * nodes, and the query fixes that shape, so a cleaned-up query keeps them. A * per-row child Collection that starts changes nothing in the tree, so it - * emits no `errors:change`; otherwise every new parent row would rebuild it. + * emits no `health:change`; otherwise every new parent row would rebuild it. */ // A pooled view's tree keeps node identity, as every tree does, whatever // alias its query gives the source, including the alias `root` that the @@ -5118,13 +5118,13 @@ describe(`pooled view node identity`, () => { const subscription = view.subscribeChanges(() => {}) try { await source.preload() - const first = view.errors + const first = view.health expect(first.sources.map((node) => node.alias)).toEqual([alias]) - expect(view.errors, `a second read gives the same tree`).toBe(first) + expect(view.health, `a second read gives the same tree`).toBe(first) params.reportError(new Error(`stalled`)) - const reported = view.errors + const reported = view.health expect(reported).not.toBe(first) - expect(view.errors, `the changed tree is stable too`).toBe(reported) + expect(view.health, `the changed tree is stable too`).toBe(reported) expect(reported.sources[0]!.failureReason?.message).toBe(`stalled`) } finally { subscription.unsubscribe() @@ -5175,25 +5175,25 @@ describe(`include nodes`, () => { A.write({ id: 1, parentId: 1 }) A.ready() await preload - const includes = () => query.errors.sources.slice(1) + const includes = () => query.health.sources.slice(1) expect(includes().map((node) => [node.alias, node.kind])).toEqual([ [`a`, `include`], [`a`, `include`], ]) let events = 0 - const stop = query.on(`errors:change`, () => { + const stop = query.on(`health:change`, () => { events++ }) // New parent rows start healthy child Collections. Every tree change is // announced, so with no event the tree is the same object. - const treeBefore = query.errors + const treeBefore = query.health B.write({ id: 3, parentId: 3 }) B.write({ id: 4, parentId: 4 }) await flushPromises() stop() - expect(events, `healthy children emit no errors:change`).toBe(0) - expect(query.errors, `no tree change without an event`).toBe(treeBefore) + expect(events, `healthy children emit no health:change`).toBe(0) + expect(query.health, `no tree change without an event`).toBe(treeBefore) // The tree's shape comes from the query, so a cleaned-up query keeps // its include nodes. diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index 596e602a4c..03fd18ecea 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -1858,8 +1858,8 @@ function expectGaveUpAfterReadiness( collection: Collection<OracleRow, string | number>, ) { expect(collection.status).toBe(`ready`) - expect(collection.errors.failureReason).toBeNull() - expect(collection.errors.error).toBeTruthy() + expect(collection.health.failureReason).toBeNull() + expect(collection.health.error).toBeTruthy() } type ProcessRuntime = { diff --git a/packages/powersync-db-collection/tests/on-demand-sync.test.ts b/packages/powersync-db-collection/tests/on-demand-sync.test.ts index f9c546d274..f471be1972 100644 --- a/packages/powersync-db-collection/tests/on-demand-sync.test.ts +++ b/packages/powersync-db-collection/tests/on-demand-sync.test.ts @@ -3252,8 +3252,8 @@ describe(`On-Demand Sync Mode`, () => { // After the first ready, failures don't change status. Instead, they're // reported as a failure reason. Status stays ready; data stays available. expect(collection.status).toBe(`ready`) - expect(collection.errors.hasErrors).toBe(true) - expect(collection.errors.failureReason?.message).toMatch( + expect(collection.health.hasErrors).toBe(true) + expect(collection.health.failureReason?.message).toMatch( `trigger installation failed`, ) } finally { @@ -3288,7 +3288,7 @@ describe(`On-Demand Sync Mode`, () => { // The outage keeps the rows and the ready status. The adapter retries // the rebuild, so it reports a failure reason. await vi.waitFor(() => - expect(collection.errors.failureReason).toMatchObject({ + expect(collection.health.failureReason).toMatchObject({ message: `release rebuild failed`, }), ) @@ -3308,7 +3308,7 @@ describe(`On-Demand Sync Mode`, () => { ) // A rebuild whose baseline applied reports healthy again. await vi.waitFor(() => - expect(collection.errors.hasErrors).toBe(false), + expect(collection.health.hasErrors).toBe(false), ) expect(collection.status).toBe(`ready`) expect([...collection.keys()]).toEqual( diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index a1ca2cfde7..01b0db642c 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -322,8 +322,8 @@ export interface QueryCollectionUtils< /** Execute direct writes as one atomic batch. Resolves when the sync commit applies. */ writeBatch: (callback: () => void) => Promise<void> - // Query Observer State (getters). Failures appear in `collection.errors` - // and each live query's `errors` tree. + // Query Observer State (getters). Failures appear in `collection.health` + // and each live query's `health` tree. /** Check if query is currently fetching (initial or background) */ isFetching: boolean /** Check if query is refetching in background (not initial fetch) */ diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index 0d93aeba0a..83fb0818bb 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -20,7 +20,7 @@ import type { LoadSubsetOptions, SyncMetadataApi } from '@tanstack/db' */ function reportedError(collection: { status: string - errors: { + health: { failureReason: { cause: unknown } | null error: { cause: unknown } | null } @@ -28,7 +28,7 @@ function reportedError(collection: { }): unknown { return collection.status === `error` ? collection._lifecycle.getSyncError() - : (collection.errors.failureReason ?? collection.errors.error)?.cause + : (collection.health.failureReason ?? collection.health.error)?.cause } /** @@ -1203,8 +1203,8 @@ async function expectRefetchFailureIsHealth(): Promise<void> { ) const observe = () => ({ status: collection.status, - failureReason: collection.errors.failureReason?.cause ?? null, - error: collection.errors.error?.cause ?? null, + failureReason: collection.health.failureReason?.cause ?? null, + error: collection.health.error?.cause ?? null, rows: collection.toArray.map((row) => row.id), }) try { @@ -1217,7 +1217,7 @@ async function expectRefetchFailureIsHealth(): Promise<void> { }) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(true)) + await vi.waitFor(() => expect(collection.health.hasErrors).toBe(true)) expect(observe()).toEqual({ status: `ready`, failureReason: error, @@ -1226,7 +1226,7 @@ async function expectRefetchFailureIsHealth(): Promise<void> { }) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(false)) + await vi.waitFor(() => expect(collection.health.hasErrors).toBe(false)) expect(observe()).toEqual({ status: `ready`, failureReason: null, @@ -1273,22 +1273,22 @@ async function expectHealthWaitsForEveryFailingQuery(): Promise<void> { await collection._sync.loadSubset({ limit: 1 }) await collection._sync.loadSubset({ limit: 2 }) expect(collection.status).toBe(`ready`) - expect(collection.errors.hasErrors).toBe(false) + expect(collection.health.hasErrors).toBe(false) failing.add(1) await collection.utils.refetch() - await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(true)) - expect(collection.errors.failureReason?.message).toBe(`subset 1 failed`) + await vi.waitFor(() => expect(collection.health.hasErrors).toBe(true)) + expect(collection.health.failureReason?.message).toBe(`subset 1 failed`) // A subset that starts after the failure and succeeds does not make the // failing subset healthy. await collection._sync.loadSubset({ limit: 3 }) await vi.waitFor(() => expect(collection.has(`row-3`)).toBe(true)) - expect(collection.errors.failureReason?.message).toBe(`subset 1 failed`) + expect(collection.health.failureReason?.message).toBe(`subset 1 failed`) failing.clear() await collection.utils.refetch() - await vi.waitFor(() => expect(collection.errors.hasErrors).toBe(false)) + await vi.waitFor(() => expect(collection.health.hasErrors).toBe(false)) expect(collection.status).toBe(`ready`) } finally { loggedError.mockRestore() diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 8ba0d64d37..c60d302e2d 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -51,7 +51,7 @@ import type { QueryCollectionUtils } from '../src/query.js' */ function reportedError(collection: { status: string - errors: { + health: { failureReason: { cause: unknown } | null error: { cause: unknown } | null } @@ -59,7 +59,7 @@ function reportedError(collection: { }): unknown { return collection.status === `error` ? collection._lifecycle.getSyncError() - : (collection.errors.failureReason ?? collection.errors.error)?.cause + : (collection.health.failureReason ?? collection.health.error)?.cause } /** diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 966ca56df6..cc55f14f69 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -49,7 +49,7 @@ import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' */ function reportedError(collection: { status: string - errors: { + health: { failureReason: { cause: unknown } | null error: { cause: unknown } | null } @@ -57,7 +57,7 @@ function reportedError(collection: { }): unknown { return collection.status === `error` ? collection._lifecycle.getSyncError() - : (collection.errors.failureReason ?? collection.errors.error)?.cause + : (collection.health.failureReason ?? collection.health.error)?.cause } interface TestItem { @@ -11767,8 +11767,8 @@ describe(`reload settles on the new run's first result`, () => { ]) expect(settled).toBe(outcome === `ok` ? `resolved` : `rejected`) expect(collection.status).toBe(outcome === `ok` ? `ready` : before) - expect(collection.errors.reloading).toBe(false) - expect(collection.errors.error?.message ?? null).toBe( + expect(collection.health.reloading).toBe(false) + expect(collection.health.error?.message ?? null).toBe( outcome === `ok` ? null : `backend down`, ) if (outcome === `ok`) expect(collection.size).toBe(1) @@ -11805,7 +11805,7 @@ describe(`reload settles on the new run's first result`, () => { await collection.preload() await collection.reload().catch(() => undefined) expect(collection.status).toBe(`ready`) - expect(collection.errors.error?.message).toBe(`backend down`) + expect(collection.health.error?.message).toBe(`backend down`) collection.utils.writeInsert({ id: 3 }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(collection.toArray.map((row) => row.id).sort()).toEqual([1, 3]) @@ -11869,7 +11869,7 @@ describe(`failureReason follows the still-failing Queries`, () => { await query.fetch().catch(() => undefined) await flushPromises() } - const shown = () => collection.errors.failureReason?.message ?? null + const shown = () => collection.health.failureReason?.message ?? null try { await collection._sync.loadSubset({ limit: 1 }) await collection._sync.loadSubset({ limit: 2 }) @@ -11936,7 +11936,7 @@ describe(`failureReason follows the still-failing Queries`, () => { await query.fetch().catch(() => undefined) await flushPromises() } - const shown = () => collection.errors.failureReason?.message ?? null + const shown = () => collection.health.failureReason?.message ?? null try { await collection._sync.loadSubset({ limit: 1 }) await collection._sync.loadSubset({ limit: 2 }) diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 5e6ac457c6..a5ce3e69b2 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -72,7 +72,7 @@ const { isError, isIdle, isCleanedUp, - errors, // error tree: errors.hasErrors means data may be stale + health, // health tree: health.hasErrors means data may be stale } = useLiveQuery({ query: (q) => q @@ -101,7 +101,7 @@ const { data, status } = useLiveQuery({ // When disabled: status='disabled', data=undefined ``` -`isError` means the query must restart (its initial load failed or a source crashed). After the query is ready, sync problems never set it: read `errors?.hasErrors` from the error tree to show stale-data warnings. +`isError` means the query must restart (its initial load failed or a source crashed). After the query is ready, sync problems never set it: read `health?.hasErrors` from the health tree to show stale-data warnings. ### useLiveSuspenseQuery diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index a712c7a6f3..04ba8f90ba 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -38,7 +38,7 @@ import type { CollectionStatus, Context, DbClient, - ErrorTreeNode, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryWindowController, @@ -94,7 +94,7 @@ export type UseLiveInfiniteQueryReturnWithCollection< /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean - /** The data is shown, and a sync problem or failed page load is in `errors`. */ + /** The data is shown, and a sync problem or failed page load is in `health`. */ isSyncError: boolean isEnabled: true pages: Array<Array<TResult>> @@ -102,7 +102,7 @@ export type UseLiveInfiniteQueryReturnWithCollection< fetchNextPage: () => Promise<void> hasNextPage: boolean isFetchingNextPage: boolean - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } type EnabledLiveQueryReturn<TContext extends Context> = ReturnType< @@ -471,6 +471,6 @@ export function useLiveInfiniteQuery<TContext extends Context>( fetchNextPage, hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, - errors: snapshot.errors, + health: snapshot.health, } } diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index c142a9701e..f56882bbbd 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -27,8 +27,8 @@ import type { CollectionStatus, Context, DbClient, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -324,9 +324,9 @@ type UseLiveQueryFlags = { /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: boolean - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } /** diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 9a435d33ac..cca2b6febb 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -8,8 +8,8 @@ import type { Collection, Context, DbClient, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -156,7 +156,7 @@ export function useLiveSuspenseQuery<TContext extends Context>( state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } // Overload 2: Accept config object @@ -166,7 +166,7 @@ export function useLiveSuspenseQuery<TContext extends Context>( state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } // Overload 3: Accept legacy config object @@ -177,7 +177,7 @@ export function useLiveSuspenseQuery<TContext extends Context>( state: Map<string | number, GetResult<TContext>> data: InferResultType<TContext> collection: Collection<GetResult<TContext>, string | number, {}> - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } // Overload 4: Accept pre-created live query collection @@ -191,7 +191,7 @@ export function useLiveSuspenseQuery< state: Map<TKey, TResult> data: Array<TResult> collection: Collection<TResult, TKey, TUtils> - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } // Overload 5: Accept pre-created live query collection with singleResult: true @@ -205,7 +205,7 @@ export function useLiveSuspenseQuery< state: Map<TKey, TResult> data: TResult | undefined collection: Collection<TResult, TKey, TUtils> & SingleResult - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } // Implementation - uses useLiveQuery internally and adds Suspense logic @@ -285,7 +285,7 @@ export function useLiveSuspenseQuery( } // A failed first load throws only before the first ready; later sync - // problems surface in `errors` with the data kept (as in TanStack Query). + // problems surface in `health` with the data kept (as in TanStack Query). // A crash throws at any time: the Collection can no longer serve reads. if ( collectionStatus === `crashed` || @@ -293,7 +293,7 @@ export function useLiveSuspenseQuery( ) { promiseRef.current = null throw ( - result.collection.errors.error?.cause ?? + result.collection.health.error?.cause ?? new Error(`Collection "${result.collection.id}" failed to load`) ) } @@ -356,6 +356,6 @@ export function useLiveSuspenseQuery( state: result.state, data: result.data, collection: result.collection, - errors: result.errors, + health: result.health, } } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index f36fd075f2..c96f492620 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -172,7 +172,7 @@ function makeHandle(hook: RenderHookResult<any, any>) { // Read react-db's real `isEnabled` field so the suite catches a broken // one (deriving from status would mask it). isEnabled: r?.isEnabled, - errors: r?.errors, + health: r?.health, }) }, async flush() { diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 80f8848d64..d2a3f63c02 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1024,9 +1024,9 @@ describe(`Query Collections`, () => { expect(result.current.data[0]?.text).toBe(`Todo 1`) // After Collection readiness, a final health error leaves the initial - // gate alone: status and rows stay, and only the error tree shows it. + // gate alone: status and rows stay, and only the health tree shows it. act(() => failSource()) - await waitFor(() => expect(result.current.errors?.hasErrors).toBe(true)) + await waitFor(() => expect(result.current.health?.hasErrors).toBe(true)) expect(result.current.status).toBe(`ready`) expect(result.current.isLoadingError).toBe(false) expect(result.current.data[0]?.text).toBe(`Todo 1`) @@ -1041,7 +1041,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).toBe(todosQuery) expect(result.current.data[0]?.text).toBe(`Todo 2`) // Only a restart clears a final health error. - expect(result.current.errors?.hasErrors).toBe(false) + expect(result.current.health?.hasErrors).toBe(false) } finally { unmount() reports.mockRestore() diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index d055b17330..708b773194 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -176,7 +176,7 @@ describe(`RxDB Integration`, () => { expect(collection.status).toBe(outcome === `ok` ? `ready` : `error`) if (outcome === `ok`) expect(collection.size).toBe(1) else - expect(collection.errors.error?.message).toBe(`reload query failed`) + expect(collection.health.error?.message).toBe(`reload query failed`) } finally { query.mockRestore() await collection.cleanup() @@ -229,8 +229,8 @@ describe(`RxDB Integration`, () => { changes.error(streamError) expect(collection.status).toBe(`ready`) expect(collection.size).toBe(1) - expect(collection.errors.error?.message).toBe(streamError.message) - expect(collection.errors.failureReason).toBeNull() + expect(collection.health.error?.message).toBe(streamError.message) + expect(collection.health.failureReason).toBeNull() } expect(OPEN_RXDB_SUBSCRIPTIONS.get(rxCollection)?.size ?? 0).toBe(0) } finally { diff --git a/packages/solid-db/skills/solid-db/SKILL.md b/packages/solid-db/skills/solid-db/SKILL.md index 38b3cd64f2..4d49fb806a 100644 --- a/packages/solid-db/skills/solid-db/SKILL.md +++ b/packages/solid-db/skills/solid-db/SKILL.md @@ -62,7 +62,7 @@ const query = useLiveQuery((q) => q.from({ todo: todoCollection })) // query.data → DEPRECATED — use query() instead. Migrate any existing code. // query.status → CollectionStatus // query.isLoading, query.isReady, query.isError (query must restart) -// query.errors → error tree; query.errors?.hasErrors means data may be stale +// query.health → health tree; query.health?.hasErrors means data may be stale // query.state → ReactiveMap<TKey, T> // query.collection → Collection diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 0933d27e09..715ba04cc2 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -22,8 +22,8 @@ import type { Collection, CollectionStatus, Context, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -50,9 +50,9 @@ type UseLiveQueryFlags = { /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: boolean - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } /** @@ -345,7 +345,7 @@ export function useLiveQuery( name: `TanstackDBStatus`, }, ) - const [errors, setErrors] = createSignal<ErrorTreeNode | undefined>(undefined) + const [health, setHealth] = createSignal<HealthNode | undefined>(undefined) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( @@ -409,7 +409,7 @@ export function useLiveQuery( return [] } setStatus(currentCollection.status) - setErrors(currentCollection.errors) + setHealth(currentCollection.health) try { const observer = createLiveQueryObserver(currentCollection) localWait = { @@ -457,7 +457,7 @@ export function useLiveQuery( } syncDataFromCollection(currentCollection) setStatus(currentCollection.status) - setErrors(currentCollection.errors) + setHealth(currentCollection.health) }) return data }, @@ -472,7 +472,7 @@ export function useLiveQuery( const currentCollection = collection() if (!currentCollection) { setStatus(`disabled` as const) - setErrors(undefined) + setHealth(undefined) state.clear() rowsByKey.clear() rowsCollection = undefined @@ -519,7 +519,7 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) const snapshot = observer.getSnapshot() setStatus(snapshot.status) - setErrors(snapshot.errors) + setHealth(snapshot.health) }) }, ) @@ -534,7 +534,7 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) const snapshot = observer.getSnapshot() setStatus(snapshot.status) - setErrors(snapshot.errors) + setHealth(snapshot.health) }) onCleanup(() => { @@ -612,12 +612,12 @@ export function useLiveQuery( }, isSyncError: { get() { - return status() === `ready` && errors()?.hasErrors === true + return status() === `ready` && health()?.hasErrors === true }, }, - errors: { + health: { get() { - return errors() + return health() }, }, }) diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 3887fdb862..d3572fb6e2 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -138,7 +138,7 @@ function makeHandle( isSyncError: result?.isSyncError, // solid-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result?.status !== `disabled`, - errors: result?.errors, + health: result?.health, }) }, flush: settle, diff --git a/packages/solid-db/tests/suspense-after-readiness.test.tsx b/packages/solid-db/tests/suspense-after-readiness.test.tsx index ad605aa182..39bb53d756 100644 --- a/packages/solid-db/tests/suspense-after-readiness.test.tsx +++ b/packages/solid-db/tests/suspense-after-readiness.test.tsx @@ -18,7 +18,7 @@ import type { SyncConfig } from '@tanstack/db' const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) type Post = { id: string; title: string } -type SyncParams = Parameters<SyncConfig<Post, string>[`sync`]>[0] +type SyncParams = Parameters<SyncConfig<Post, string | number>[`sync`]>[0] describe(`useLiveQuery under Suspense after readiness`, () => { it(`keeps the rows shown when the source reports a problem or stops`, async () => { @@ -36,10 +36,13 @@ describe(`useLiveQuery under Suspense after readiness`, () => { }, }, }) - let query: ReturnType<typeof useLiveQuery> | undefined + let status: (() => string) | undefined + let cleanupQuery: (() => Promise<void>) | undefined function Title() { - query = useLiveQuery((q) => q.from({ post: source })) - return <div data-testid="title">{(query() as Array<Post>)[0]?.title}</div> + const query = useLiveQuery((q) => q.from({ post: source })) + status = () => query.status + cleanupQuery = () => query.collection.cleanup() + return <div data-testid="title">{query()[0]?.title}</div> } const view = render(() => ( <Suspense fallback={<div data-testid="waiting">Waiting</div>}> @@ -52,21 +55,21 @@ describe(`useLiveQuery under Suspense after readiness`, () => { params!.reportError(new Error(`source problem`)) await flush() - expect(query!.status).toBe(`ready`) - expect(source.errors.failureReason?.message).toBe(`source problem`) + expect(status!()).toBe(`ready`) + expect(source.health.failureReason?.message).toBe(`source problem`) expect(view.queryByTestId(`waiting`)).toBeNull() expect(view.getByTestId(`title`).textContent).toBe(`one`) params!.fail(new Error(`source gave up`)) await flush() - expect(query!.status).toBe(`ready`) - expect(source.errors.error?.message).toBe(`source gave up`) + expect(status!()).toBe(`ready`) + expect(source.health.error?.message).toBe(`source gave up`) expect(view.queryByTestId(`waiting`)).toBeNull() expect(view.getByTestId(`title`).textContent).toBe(`one`) } finally { view.unmount() await waitFor(() => expect(true).toBe(true)) - await query?.collection.cleanup() + await cleanupQuery?.() await source.cleanup() } }) diff --git a/packages/svelte-db/skills/svelte-db/SKILL.md b/packages/svelte-db/skills/svelte-db/SKILL.md index bbda280f85..9b9f81fa71 100644 --- a/packages/svelte-db/skills/svelte-db/SKILL.md +++ b/packages/svelte-db/skills/svelte-db/SKILL.md @@ -59,7 +59,7 @@ Returns a reactive object with getter properties: // Query function — access via dot notation const query = useLiveQuery((q) => q.from({ todo: todoCollection })) // query.data, query.isLoading, query.status, etc. -// query.errors → error tree; query.errors?.hasErrors means data may be stale +// query.health → health tree; query.health?.hasErrors means data may be stale // With reactive deps — use GETTER FUNCTIONS let minPriority = $state(5) diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 07ba3b2804..e5082bb15e 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -277,8 +277,8 @@ export function useLiveInfiniteQuery<TContext extends Context>( get isFetchingNextPage() { return snapshot.isFetchingNextPage }, - get errors() { - return snapshot.errors + get health() { + return snapshot.health }, fetchNextPage, } diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index d0108d344e..9d5dd3e27b 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -23,8 +23,8 @@ import type { Context, DbClient, DeferredLiveQueryCollections, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -54,7 +54,7 @@ function snapshotRows(data: unknown): Array<any> { * @property isIdle - True when query hasn't started yet * @property isLoadingError - True when the first load failed * @property isCrashed - True when local storage or an invariant broke - * @property isSyncError - True when data is shown and `errors` has a problem + * @property isSyncError - True when data is shown and `health` has a problem * @property isCleanedUp - True when query has been cleaned up */ export interface UseLiveQueryReturn<T extends object, TData = Array<T>> { @@ -70,9 +70,9 @@ export interface UseLiveQueryReturn<T extends object, TData = Array<T>> { /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: boolean - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } type InferConditionalResultType<TContext extends Context> = @@ -106,9 +106,9 @@ export interface UseLiveQueryReturnWithCollection< /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: boolean isCleanedUp: boolean - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: boolean - errors: ErrorTreeNode | undefined + health: HealthNode | undefined } type MaybeGetter<T> = T | (() => T) @@ -470,7 +470,7 @@ export function useLiveQuery( // Track collection status reactively let status = $state(initialSnapshot.status) - let errors = $state.raw<ErrorTreeNode | undefined>(initialSnapshot.errors) + let health = $state.raw<HealthNode | undefined>(initialSnapshot.health) const syncFromObserver = ( observer: LiveQueryObserver<any, any>, @@ -478,7 +478,7 @@ export function useLiveQuery( ) => { const snapshot = observer.getSnapshot() status = snapshot.status as CollectionStatus - errors = snapshot.errors + health = snapshot.health untrack(() => { if (changes && changes.length > 0) { for (const change of changes) { @@ -576,10 +576,10 @@ export function useLiveQuery( return status === `cleaned-up` }, get isSyncError() { - return status === `ready` && errors?.hasErrors === true + return status === `ready` && health?.hasErrors === true }, - get errors() { - return errors + get health() { + return health }, } } diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index b1ca19f9b4..ba350edf98 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -133,7 +133,7 @@ function makeHandle(getQuery: () => any, dispose: () => void): LiveQueryHandle { isLoadingError: query?.isLoadingError, isCrashed: query?.isCrashed, isSyncError: query?.isSyncError, - errors: query?.errors, + health: query?.health, // svelte-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: query?.status !== `disabled`, }) diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index 20ec5b01db..77cbc188b2 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -355,7 +355,7 @@ async function checkLifecycle( plan.ending === `read-error` || plan.ending === `parse-error` if (!plan.immediate && plan.ending !== `cleanup`) { expect( - collection.errors, + collection.health, `${epoch}:${plan.ending} error`, ).toMatchObject({ hasErrors: true, @@ -1232,7 +1232,7 @@ it.each([`close`, `read-error`] as const)( failureReason: null, error: expect.anything(), } - expect(collection.errors, `${ending} error after the end`).toMatchObject( + expect(collection.health, `${ending} error after the end`).toMatchObject( stopped, ) @@ -1250,7 +1250,7 @@ it.each([`close`, `read-error`] as const)( expect(rows(), `${ending} a later load lists current rows`).toEqual( sortedRows(service.values()), ) - expect(collection.errors, `${ending} error after the load`).toMatchObject( + expect(collection.health, `${ending} error after the load`).toMatchObject( stopped, ) } finally { diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index 29a1bf0183..2edc529c33 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -272,7 +272,7 @@ describe(`TrailBase Integration`, () => { // `error`, not a log line; the rows and status stay. expect(reported).not.toHaveBeenCalled() expect(collection.status).toBe(`ready`) - expect(collection.errors.error).toMatchObject({ cause: failure }) + expect(collection.health.error).toMatchObject({ cause: failure }) expect(cancel).toHaveBeenCalledOnce() expect(stream.locked).toBe(false) await collection.cleanup() @@ -331,7 +331,7 @@ describe(`TrailBase Integration`, () => { // The adapter does not resubscribe, so every end of the stream is a // stop: the Collection's `error`; a stream failure is its cause. expect(reported).not.toHaveBeenCalled() - expect(collection.errors.error).toMatchObject( + expect(collection.health.error).toMatchObject( ending === `error` ? { cause: failure } : {}, ) diff --git a/packages/vue-db/skills/vue-db/SKILL.md b/packages/vue-db/skills/vue-db/SKILL.md index 09f552c407..b5486265c7 100644 --- a/packages/vue-db/skills/vue-db/SKILL.md +++ b/packages/vue-db/skills/vue-db/SKILL.md @@ -3,8 +3,8 @@ name: vue-db description: > Vue 3 bindings for TanStack DB. useLiveQuery composable with MaybeRefOrGetter query functions, ComputedRef return values for all fields - (data, state, collection, status, isLoading, isReady, isError, errors). - isError means the query must restart; errors.hasErrors means stale data. + (data, state, collection, status, isLoading, isReady, isError, health). + isError means the query must restart; health.hasErrors means stale data. Dependency arrays with Vue refs. Conditional queries via returning undefined/null. Pre-created collection support via ref or getter. Import from @tanstack/vue-db (re-exports all of @tanstack/db). diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 9914c68fbf..df1b0295a1 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -14,8 +14,8 @@ import type { Collection, CollectionStatus, Context, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, NonSingleResult, @@ -62,14 +62,14 @@ export interface UseLiveInfiniteQueryReturn< /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: ComputedRef<boolean> isCleanedUp: ComputedRef<boolean> - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: ComputedRef<boolean> pages: ComputedRef<Array<Array<InferResultType<TContext>[number]>>> pageParams: ComputedRef<Array<number>> fetchNextPage: () => Promise<void> hasNextPage: ComputedRef<boolean> isFetchingNextPage: ComputedRef<boolean> - errors: ComputedRef<ErrorTreeNode | undefined> + health: ComputedRef<HealthNode | undefined> } export interface UseLiveInfiniteQueryReturnWithCollection< @@ -89,14 +89,14 @@ export interface UseLiveInfiniteQueryReturnWithCollection< /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: ComputedRef<boolean> isCleanedUp: ComputedRef<boolean> - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: ComputedRef<boolean> pages: ComputedRef<Array<Array<TResult>>> pageParams: ComputedRef<Array<number>> fetchNextPage: () => Promise<void> hasNextPage: ComputedRef<boolean> isFetchingNextPage: ComputedRef<boolean> - errors: ComputedRef<ErrorTreeNode | undefined> + health: ComputedRef<HealthNode | undefined> } /** @@ -271,6 +271,6 @@ export function useLiveInfiniteQuery< fetchNextPage: () => fetchNextLiveQueryWindowPage(controller.value), hasNextPage: computed(() => snapshot.value.hasNextPage), isFetchingNextPage: computed(() => snapshot.value.isFetchingNextPage), - errors: computed(() => snapshot.value.errors), + health: computed(() => snapshot.value.health), } } diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 52732dd8c1..bc6a605bf8 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -21,8 +21,8 @@ import type { Collection, CollectionStatus, Context, - ErrorTreeNode, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -64,9 +64,9 @@ export interface UseLiveQueryFlags { /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ isCrashed: ComputedRef<boolean> isCleanedUp: ComputedRef<boolean> - /** The data is shown, and a sync problem or failed request is in `errors`. */ + /** The data is shown, and a sync problem or failed request is in `health`. */ isSyncError: ComputedRef<boolean> - errors: ComputedRef<ErrorTreeNode | undefined> + health: ComputedRef<HealthNode | undefined> } export interface UseLiveQueryReturn< @@ -364,7 +364,7 @@ export function useLiveQuery( const status = ref( collection.value ? collection.value.status : (`disabled` as const), ) - const errors = shallowRef<ErrorTreeNode | undefined>(undefined) + const health = shallowRef<HealthNode | undefined>(undefined) // The shared observer owns subscription, the ready-race, and status; Vue // materializes into its own reactive map (granular) + ordered array. @@ -373,7 +373,7 @@ export function useLiveQuery( const syncFromObserver = (observer: LiveQueryObserver<any, any>) => { const snapshot = observer.getSnapshot() status.value = snapshot.status as CollectionStatus - errors.value = snapshot.errors + health.value = snapshot.health internalData.value = snapshotRows(snapshot.data) } @@ -388,7 +388,7 @@ export function useLiveQuery( // Handle null collection (disabled query) if (!currentCollection) { status.value = `disabled` as const - errors.value = undefined + health.value = undefined state.clear() internalData.value = [] return @@ -456,8 +456,8 @@ export function useLiveQuery( isCrashed: computed(() => status.value === `crashed`), isCleanedUp: computed(() => status.value === `cleaned-up`), isSyncError: computed( - () => status.value === `ready` && errors.value?.hasErrors === true, + () => status.value === `ready` && health.value?.hasErrors === true, ), - errors: computed(() => errors.value), + health: computed(() => health.value), } } diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 7303c57c41..e91270c0ec 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -134,7 +134,7 @@ function makeHandle(result: any, scope: ReturnType<typeof effectScope>) { isSyncError: result.isSyncError?.value, // vue-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: result.status?.value !== `disabled`, - errors: result.errors?.value, + health: result.health?.value, }) }, flush: settle, From 46fe9e7d8d73908bc3fabf305b67788405640f48 Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Thu, 22 Oct 2026 16:56:24 -0600 Subject: [PATCH 101/106] fix(db): a truncate after a reload's replay replays the demand again The truncate listener compared a stored replay that was never cleared. After a reload's replay failed, every later truncate was skipped, and a must-refetch after the reload committed also skipped. The query's children then never loaded again. The subscription now marks only that the reload in flight started its replay, and skips only a truncate before that reload's first commit. One helper decides which demand replays. Oracle (source-health, from the medium review): - Lazy grammar: the child adapter's own `truncate()`. The truncate replay must request every needed key, except one whose load threw. Older replay work holds the replay and decides nothing. The pushed head's guard fails it. - Lazy model: a held load's success decides its keys; a demand change's replacement releases older loads only when it applies. - Main grammar: the direct subscriber is a generated choice, and a source that starts again after cleanup gets a fresh one. - `fromReplay` is required on a lazy load. Co-authored-by: Isaac <no-reply@databricks.com> --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 5 +- packages/db/src/collection/subscription.ts | 50 ++--- .../db/tests/source-health-oracle.test.ts | 197 ++++++++++++++---- 4 files changed, 186 insertions(+), 68 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index e4ff30c6b2..385309484f 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses the child adapter's own `truncate()` (a must-refetch in the same sync run: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 7c7a279a3f..8d8d41bd1c 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -431,8 +431,9 @@ "recordOutcome": "gP", "replacements": "dp", "onLoaded": "gR", - "reloadReplay": "gU", "publishedHealth": "gK", "reportHealth": "gT", - "healthTree": "gV" + "healthTree": "gV", + "reloadReplaying": "gU", + "demandsToReplay": "gY" } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 6bd029091a..11f2a9c874 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -205,8 +205,9 @@ export class CollectionSubscription // and buffered changes until every attempt settles. private truncateReplayState: TruncateReplayState | undefined private replacements = 0 - // The replay a reload started when it retired the sync run. - private reloadReplay: TruncateReplayState | undefined + // The reload in flight started this subscription's replay when it retired + // the sync run. + private reloadReplaying = false private readonly loadSubsetPromiseErrors = new WeakMap< Promise<unknown>, Error @@ -270,9 +271,11 @@ export class CollectionSubscription // We need to re-request all previously loaded subsets to repopulate the data. this.truncateCleanup = this.collection.on(`truncate`, () => { // A reload that replays on-demand demand started its replay when it - // retired the run; the truncate of its first commit is part of it. - if (this.reloadReplay !== this.truncateReplayState || !this.reloadReplay) - this.handleTruncate() + // retired the run. A truncate before the reload's first commit is part + // of that replay; no replay row has applied yet. + if (this.reloadReplaying && this.collection._lifecycle.reloading) return + this.reloadReplaying = false + this.handleTruncate() }) this.collectionCleanup = this.collection._onCleanupStart(() => { this.handleCollectionCleanup() @@ -322,21 +325,26 @@ export class CollectionSubscription /** * @internal A reload retired the sync run. Its demand detaches from the - * retired run and replays through the new run at once, as after a - * truncate, so a consumer of an aborted load follows that replay. + * retired run. On-demand demand replays through the new run at once, as + * after a truncate, so a consumer of an aborted load follows that replay. + * Without such demand, the retained rows stay published until the + * reload's first commit truncates them. */ public retireSyncRun(): void { this.handleCollectionCleanup(true) - // Other demand keeps its retained rows published until the reload's - // first commit truncates them. - if ( - this.collection.config.syncMode === `on-demand` && - !this.defersAcquisition && - this.subsetDemands.length > 0 - ) { - this.handleTruncate() - this.reloadReplay = this.truncateReplayState - } + this.reloadReplaying = this.demandsToReplay().length > 0 + if (this.reloadReplaying) this.handleTruncate() + } + + /** The demand a truncate acquires again through the sync adapter. */ + private demandsToReplay(): Array<SubsetDemand> { + // Eager demand is satisfied by source readiness, even when the adapter + // happens to expose a loader. Deferred demand never acquired, so it + // acquires the replacement rows when acquisition resumes. + return this.collection.config.syncMode === `on-demand` && + !this.defersAcquisition + ? [...this.subsetDemands] + : [] } /** Detach logical demand from work owned by a discarded sync run. */ @@ -435,15 +443,9 @@ export class CollectionSubscription */ private handleTruncate() { this.replacements++ - // Eager demand is satisfied by source readiness, even when the adapter - // happens to expose a loader. Only on-demand demand reacquires a subset. // A reload's truncate can apply inside the new run's `sync()`, before it // returns its loader; the requests below start after it has. - const reloadsSubsets = this.collection.config.syncMode === `on-demand` - // Deferred demand never acquired, so a truncate gives it nothing to - // reload; it acquires the replacement rows when acquisition resumes. - const demandsToReload = - reloadsSubsets && !this.defersAcquisition ? [...this.subsetDemands] : [] + const demandsToReload = this.demandsToReplay() // Retained rows still need the committed replacement even without demand. if (demandsToReload.length === 0 && this.stalePublishedRows.size === 0) { diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 25ced353bd..8b2a782be7 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -1359,6 +1359,7 @@ async function withDeadline(work: Promise<void>): Promise<void> { async function runHistory( shape: Shape, steps: ReadonlyArray<Step>, + directSubscribers = true, ): Promise<void> { const sources = { A: createSource(`A`), B: createSource(`B`) } const { query, inner, start, size } = buildQuery( @@ -1404,25 +1405,31 @@ async function runHistory( // after one fails. What a subscription that survives cleanup sees belongs // to the subscription lifecycle oracle, so the check stops at cleanup // start and a restart subscribes again. + // A generated history may leave them out, so the query is then a source's + // only subscriber. const mirrors = { A: new Map<number, number>(), B: new Map<number, number>() } - const mirrored = { A: true, B: true } - let mirrorSubscriptions: Array<{ unsubscribe: () => void }> = [] + const mirrored = { A: false, B: false } + const mirrorSubscriptions: Partial< + Record<SourceName, { unsubscribe: () => void }> + > = {} + const mirrorSource = (name: SourceName) => { + mirrorSubscriptions[name]?.unsubscribe() + mirrors[name] = new Map() + mirrored[name] = true + mirrorSubscriptions[name] = sources[name].collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = change.key + if (change.type === `delete`) mirrors[name].delete(key) + else mirrors[name].set(key, (change.value as Row).tag ?? 0) + } + }, + { includeInitialState: true }, + ) + } const mirror = () => { - for (const subscription of mirrorSubscriptions) subscription.unsubscribe() - mirrorSubscriptions = shapeSources[shape].map((name) => { - mirrors[name] = new Map() - mirrored[name] = true - return sources[name].collection.subscribeChanges( - (changes) => { - for (const change of changes) { - const key = change.key - if (change.type === `delete`) mirrors[name].delete(key) - else mirrors[name].set(key, (change.value as Row).tag ?? 0) - } - }, - { includeInitialState: true }, - ) - }) + if (!directSubscribers) return + for (const name of shapeSources[shape]) mirrorSource(name) } mirror() let listenersBeforeEffect = 0 @@ -1646,6 +1653,9 @@ async function runHistory( sortedTags(source.rows), ) if (source.cleaning || source.retired) mirrored[name] = false + // A source that started again after cleanup, by any path, gets a + // fresh direct subscriber. + else if (directSubscribers && !mirrored[name]) mirrorSource(name) if (mirrored[name]) expect( sortedTags(mirrors[name]), @@ -1732,7 +1742,8 @@ async function runHistory( } finally { console.error = consoleError stop() - for (const subscription of mirrorSubscriptions) subscription.unsubscribe() + for (const subscription of Object.values(mirrorSubscriptions)) + subscription.unsubscribe() await settleCleanups() await effect?.dispose() await query.cleanup() @@ -2147,11 +2158,16 @@ describe(`source failure and health oracle`, () => { }, ) fcTest.prop( - [fc.constantFrom(...shapes), fc.array(stepArb, { maxLength: 12 })], + [ + fc.constantFrom(...shapes), + fc.array(stepArb, { maxLength: 12 }), + fc.boolean(), + ], oraclePropertyOptions(60, `source-health.history`), )( `generated histories refine the model`, - (shape, steps) => withDeadline(runHistory(shape, steps)), + (shape, steps, directSubscribers) => + withDeadline(runHistory(shape, steps, directSubscribers)), 120_000, ) }) @@ -3999,6 +4015,7 @@ type LazyStep = | `settle-last-fail` | `throw-next` | `reload` + | `truncate` | `report` | `fail` | `crash` @@ -4006,11 +4023,15 @@ type LazyStep = // One load the query requested, as the adapter saw it. // `fromReplay`: the replay itself made the request, not a demand change. +// `superseded`: a newer truncate replaced the replay attempt that made it. It +// still holds the replay's completion, decides nothing, and its rows apply +// if it succeeds. type LazyLoad = { serial: number keys: Set<number> replay: boolean - fromReplay?: boolean + fromReplay: boolean + superseded: boolean } type LazyModel = { @@ -4054,12 +4075,40 @@ function settleLazyLoad( ): void { model.pending = model.pending.filter((entry) => entry !== load) if (model.gate === `crashed`) return + if (load.superseded) { + if (succeeded) for (const key of load.keys) model.loaded.add(key) + settleReplay(model) + return + } const latestFor = (key: number) => latestLoadFor(model, key) === load.serial + // A demand change's replacement that applies for every needed key releases + // the older loads it replaces: they no longer hold the first load. + if ( + succeeded && + !load.fromReplay && + [...model.parents].every((key) => load.keys.has(key)) + ) + model.retiredInFlight.clear() + if (succeeded && !load.fromReplay) + // A demand change's load that covers a superseded load's keys replaces + // that coverage. A newer replay still waits for the older work. + model.pending = model.pending.filter( + (entry) => + !entry.superseded || + ![...entry.keys] + .filter((key) => model.parents.has(key)) + .every((key) => load.keys.has(key)), + ) if (succeeded) { for (const key of load.keys) { model.loaded.add(key) - // A key loaded: its failure clears, unless a newer load of it failed. - if ((model.failed.get(key) ?? 0) < load.serial) model.failed.delete(key) + // A key loaded: its failure clears, unless a newer load of it failed + // and this load no longer holds the key (the query aborted it). + if ( + (model.failed.get(key) ?? 0) < load.serial || + model.requested.has(load.serial) + ) + model.failed.delete(key) } if (load.replay) model.replayFailed = false settleReplay(model) @@ -4095,7 +4144,11 @@ function latestLoadFor(model: LazyModel, key: number): number | undefined { // key the query still needs then fails the query's first load, or shows a // request error after it. function settleReplay(model: LazyModel): void { - if (model.pending.some((entry) => entry.replay)) return + // A superseded load holds the replay only while the query needs its data. + const holds = (entry: LazyLoad) => + entry.replay && + (!entry.superseded || [...entry.keys].some((key) => model.parents.has(key))) + if (model.pending.some(holds)) return model.replayActive = false const failed = [...model.replayFailedKeys].some((key) => model.parents.has(key), @@ -4185,6 +4238,26 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.loaded = new Set() model.replayActive = true return + case `truncate`: + // The adapter replaces every row in the same sync run, as a + // must-refetch does. The truncate replay loads the demand again. A load + // started before it decides nothing: it may still hold the first load, + // and its rows apply if it succeeds. A failed replay's error stays + // until a later replay succeeds. + if (model.source.crashed || model.source.stopped) return + for (const load of model.pending) { + if (load.replay) load.superseded = true + else { + model.retiredInFlight.set(load.serial, load.keys) + model.obsolete.set(load.serial, load.keys) + } + } + model.replayFailedKeys = new Set() + model.pending = model.pending.filter((load) => load.superseded) + model.requested = new Map() + model.loaded = new Set() + model.replayActive = true + return case `report`: if (!model.source.stopped) model.source.failure = `source problem` return @@ -4233,7 +4306,11 @@ async function runLazyHistory( serial: number deferred: ReturnType<typeof createDeferred<void>> }> = [] - const requests: Array<LazyLoad & { thrown: boolean }> = [] + const requests: Array<{ + serial: number + keys: Set<number> + thrown: boolean + }> = [] const aborted = new Set<number>() let serials = 0 let throwNext = false @@ -4241,6 +4318,9 @@ async function runLazyHistory( let childRuns = 0 let parentParams!: SyncParams let childParams!: SyncParams + let forgetServed = () => {} + // Loads that threw, by serial. + const thrownLoads = new Set<number>() const parents = createCollection<Row, number>({ id: `health-lazy-parents-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, @@ -4263,9 +4343,10 @@ async function runLazyHistory( sync: { sync: (params) => { childParams = params - // Children this sync run has served; a reload's run serves them - // again. + // Children this sync run has served; a reload's run, or a load + // after the run's own truncate, serves them again. const served = new Set<number>() + forgetServed = () => served.clear() if (childRuns++ > 0 && asyncChildReady) setTimeout(() => params.markReady(), 0) else params.markReady() @@ -4275,7 +4356,7 @@ async function runLazyHistory( const serial = ++serials const thrown = throwNext throwNext = false - requests.push({ serial, keys, replay: false, thrown }) + requests.push({ serial, keys, thrown }) if (thrown) throw new Error(`child load ${++failures} threw`) const deferred = createDeferred<void>() loads.push({ serial, deferred }) @@ -4320,10 +4401,16 @@ async function runLazyHistory( const report = (replayMade = false) => { // An aborted request is obsolete: it decides nothing, but if its run // still serves it, its rows apply when it succeeds. + // A superseded replay load the adapter still serves stays: it holds its + // replay until it settles. + const retired = (load: LazyLoad) => + aborted.has(load.serial) && + !(load.superseded && loads.some((entry) => entry.serial === load.serial)) const retireAborted = () => { for (const load of model.pending) - if (aborted.has(load.serial)) model.obsolete.set(load.serial, load.keys) - model.pending = model.pending.filter((load) => !aborted.has(load.serial)) + if (retired(load) && !load.superseded) + model.obsolete.set(load.serial, load.keys) + model.pending = model.pending.filter((load) => !retired(load)) for (const serial of aborted) model.requested.delete(serial) } // A demand change releases the loads it retires before it requests new @@ -4341,22 +4428,17 @@ async function runLazyHistory( keys: request.keys, replay: model.replayActive && (replayMade || !request.thrown), fromReplay: replayMade, + superseded: false, } model.requested.set(load.serial, load.keys) model.pending.push(load) return { load, thrown: request.thrown } }) for (const { load, thrown } of reported) - if (thrown) settleLazyLoad(model, load, false) - // A demand change that requests every needed key again replaces the - // older loads: readiness waits only for the replacement. - if ( - !replayMade && - reported.some(({ load }) => - [...model.parents].every((key) => load.keys.has(key)), - ) - ) - model.retiredInFlight.clear() + if (thrown) { + thrownLoads.add(load.serial) + settleLazyLoad(model, load, false) + } retireAborted() aborted.clear() settleReplay(model) @@ -4426,6 +4508,13 @@ async function runLazyHistory( throwNext = true } else if (step.kind === `reload`) { void children.reload().catch(() => undefined) + } else if (step.kind === `truncate`) { + if (children.status !== `crashed` && !model.source.stopped) { + childParams.begin() + childParams.truncate() + childParams.commit() + forgetServed() + } } else if (step.kind === `crash`) { children._lifecycle.crash(new Error(`children crashed`)) } else { @@ -4438,7 +4527,25 @@ async function runLazyHistory( } } await flushPromises() - report(step.kind === `reload`) + const stepRequests = [...requests] + // A load that threw holds no demand, so a replay does not load its + // key again. + report(step.kind === `reload` || step.kind === `truncate`) + // A truncate replays the query's demand: the adapter receives a + // request for every key the query still needs. + if ( + step.kind === `truncate` && + (model.gate === `ready` || model.gate === `loading`) && + !model.source.stopped + ) + expect( + [...model.parents].filter( + (key) => + !thrownLoads.has(model.failed.get(key) ?? 0) && + stepRequests.every((request) => !request.keys.has(key)), + ), + `${label}: keys the truncate replay did not request`, + ).toEqual([]) deriveLazyGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) @@ -4513,6 +4620,7 @@ describe(`lazy join demand grammar`, () => { `settle-last-fail` as const, `throw-next` as const, `reload` as const, + `truncate` as const, `report` as const, `fail` as const, `crash` as const, @@ -4521,6 +4629,13 @@ describe(`lazy join demand grammar`, () => { ) const named: Record<string, ReadonlyArray<LazyStep>> = { 'a failed first load fails the query': [{ kind: `settle-fail` }], + "a truncate after a reload's failed replay loads the demand again": [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `truncate` }, + { kind: `settle-ok` }, + ], 'a later load of the failed key clears its request error': [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, From 6bf6579d422b41a78d9e884fbb03584a27d04ed2 Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Thu, 22 Oct 2026 18:33:54 -0600 Subject: [PATCH 102/106] fix(db): skip only a reload's own truncate, not every truncate during it The subscription guessed a reload's own truncate from the reload being in flight. A reload that starts from `loading` or `error` stays in flight past its first commit, so an adapter truncate in that window was dropped and the query's children never loaded again. State now marks the truncate event that a reload's own transaction emits, and the subscription skips only that one, once, when its retire already started the replay. The demand check no longer copies the demand list. Oracle (lazy grammar): a child that holds markReady() until a `child-ready` step, with the child's initial gate in the model (a failed first load, reportError() before readiness, a query in `error` staying there), a reload in flight past its first commit, a second reload() returning the first one's promise, and rows kept until the reload's first commit. The adapter truncate in that window must replay the demand; the previous head fails it. Co-authored-by: Isaac <no-reply@databricks.com> --- docs/contributing/oracle-coverage.md | 2 +- packages/db/mangle-cache.json | 5 +- packages/db/src/collection/state.ts | 20 ++- packages/db/src/collection/subscription.ts | 34 ++--- .../db/tests/source-health-oracle.test.ts | 127 ++++++++++++++++-- 5 files changed, 152 insertions(+), 36 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 385309484f..fd0d8035e5 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses the child adapter's own `truncate()` (a must-refetch in the same sync run: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index 8d8d41bd1c..e68b7ba1e2 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -434,6 +434,7 @@ "publishedHealth": "gK", "reportHealth": "gT", "healthTree": "gV", - "reloadReplaying": "gU", - "demandsToReplay": "gY" + "emittingReloadTruncate": "gU", + "reloadReplayed": "gY", + "replaysDemand": "g6" } diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index d4719f6bbc..7d281ab571 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -121,6 +121,13 @@ export class CollectionStateManager< public transactions: SortedMap<string, Transaction<any>> /** A reload armed its truncate for the new run's next transaction. */ public reloadTruncatePending = false + // The truncate event being emitted is a reload's own first-commit truncate. + private emittingReloadTruncate = false + + /** @internal The truncate event being emitted is a reload's own. */ + public isEmittingReloadTruncate(): boolean { + return this.emittingReloadTruncate + } public pendingSyncedTransactions: Array< PendingSyncedTransaction<TOutput, TKey> > = [] @@ -1287,10 +1294,15 @@ export class CollectionStateManager< } // Emit truncate event so subscriptions can reset their cursor tracking state - this._events.emit(`truncate`, { - type: `truncate`, - collection: this.collection, - }) + this.emittingReloadTruncate = transaction.truncateOwner === `reload` + try { + this._events.emit(`truncate`, { + type: `truncate`, + collection: this.collection, + }) + } finally { + this.emittingReloadTruncate = false + } } // Attribution belongs to the whole atomic batch. A repeated write must diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 11f2a9c874..44a6da59dd 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -204,10 +204,10 @@ export class CollectionSubscription // One replay state owns the publication baseline, overlapping attempts, // and buffered changes until every attempt settles. private truncateReplayState: TruncateReplayState | undefined + // The reload that last retired the sync run started this subscription's + // replay; that reload's own truncate does not start another. + private reloadReplayed = false private replacements = 0 - // The reload in flight started this subscription's replay when it retired - // the sync run. - private reloadReplaying = false private readonly loadSubsetPromiseErrors = new WeakMap< Promise<unknown>, Error @@ -271,10 +271,13 @@ export class CollectionSubscription // We need to re-request all previously loaded subsets to repopulate the data. this.truncateCleanup = this.collection.on(`truncate`, () => { // A reload that replays on-demand demand started its replay when it - // retired the run. A truncate before the reload's first commit is part - // of that replay; no replay row has applied yet. - if (this.reloadReplaying && this.collection._lifecycle.reloading) return - this.reloadReplaying = false + // retired the run, and its own first-commit truncate is part of that + // replay. Every other truncate replays again. + const reloadTruncate = this.collection._state.isEmittingReloadTruncate() + if (reloadTruncate && this.reloadReplayed) { + this.reloadReplayed = false + return + } this.handleTruncate() }) this.collectionCleanup = this.collection._onCleanupStart(() => { @@ -332,19 +335,18 @@ export class CollectionSubscription */ public retireSyncRun(): void { this.handleCollectionCleanup(true) - this.reloadReplaying = this.demandsToReplay().length > 0 - if (this.reloadReplaying) this.handleTruncate() + this.reloadReplayed = this.replaysDemand() && this.subsetDemands.length > 0 + if (this.reloadReplayed) this.handleTruncate() } - /** The demand a truncate acquires again through the sync adapter. */ - private demandsToReplay(): Array<SubsetDemand> { + /** Whether a truncate acquires this subscription's demand again. */ + private replaysDemand(): boolean { // Eager demand is satisfied by source readiness, even when the adapter // happens to expose a loader. Deferred demand never acquired, so it // acquires the replacement rows when acquisition resumes. - return this.collection.config.syncMode === `on-demand` && - !this.defersAcquisition - ? [...this.subsetDemands] - : [] + return ( + this.collection.config.syncMode === `on-demand` && !this.defersAcquisition + ) } /** Detach logical demand from work owned by a discarded sync run. */ @@ -445,7 +447,7 @@ export class CollectionSubscription this.replacements++ // A reload's truncate can apply inside the new run's `sync()`, before it // returns its loader; the requests below start after it has. - const demandsToReload = this.demandsToReplay() + const demandsToReload = this.replaysDemand() ? [...this.subsetDemands] : [] // Retained rows still need the committed replacement even without demand. if (demandsToReload.length === 0 && this.stalePublishedRows.size === 0) { diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 8b2a782be7..29d542a790 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -4016,6 +4016,7 @@ type LazyStep = | `throw-next` | `reload` | `truncate` + | `child-ready` | `report` | `fail` | `crash` @@ -4063,7 +4064,26 @@ type LazyModel = { // latest such load decides it, so an older load that settles later changes // nothing, and an aborted one leaves the decision to the one before it. requested: Map<number, Set<number>> - source: { failure: string | null; stopped: string | null; crashed: boolean } + // `ready`: the child Collection's status is `ready`. `firstLoadFailed`: + // its sync run failed its first load (status `error`). + source: { + failure: string | null + stopped: string | null + crashed: boolean + ready: boolean + firstLoadFailed: boolean + // A reload's new run has not marked ready yet, and the child was not + // ready when it started. + reloadRun: boolean + // A reload is in flight: another reload() returns its promise. Only a + // held markReady() keeps it in flight past the step. + reloadInFlight: boolean + // The `holdChildReady` option. + holdsReady: boolean + // The reload's first commit, which replaces every child row, has not + // happened yet: the rows from before the reload stay until it does. + replacementPending: boolean + } } // A load the query requested settles. Its rows apply on success either way; @@ -4222,9 +4242,12 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { return case `reload`: if (model.source.crashed) return + if (model.source.reloadInFlight) return // The replay loads the demand again; a key it loads is loaded again. model.source.failure = null model.source.stopped = null + if (!model.source.ready) model.source.reloadRun = true + model.source.reloadInFlight = model.source.holdsReady // The retired run's loads no longer apply, and its truncate drops the // loaded children until the replay loads them again. // Only the query's own loads hold it; a replay's loads stop when a @@ -4235,7 +4258,10 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.replayFailedKeys = new Set() model.pending = [] model.obsolete = new Map() - model.loaded = new Set() + // The new run's first commit replaces every child row. Without a held + // markReady() it commits within this step. + if (model.source.holdsReady) model.source.replacementPending = true + else model.loaded = new Set() model.replayActive = true return case `truncate`: @@ -4259,16 +4285,45 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { model.replayActive = true return case `report`: - if (!model.source.stopped) model.source.failure = `source problem` + // reportError() before Collection readiness throws. + if (!model.source.stopped && model.source.ready) + model.source.failure = `source problem` return case `fail`: - if (!model.source.stopped) { - model.source.failure = null - model.source.stopped = `source gave up` + if (model.source.stopped || model.source.firstLoadFailed) return + // A reload that fails before its first commit keeps the rows. + model.source.reloadInFlight = false + model.source.replacementPending = false + if (!model.source.ready) { + // The child's first load fails: so does the query's first load. + model.source.firstLoadFailed = true + if (model.gate === `loading`) model.gate = `error` + return + } + model.source.failure = null + model.source.stopped = `source gave up` + model.source.reloadInFlight = false + return + case `child-ready`: + // The held markReady() of the child's current sync run. After a + // failed first load it throws, unless a reload started a new run. A + // reload's markReady() before any commit replaces the rows with none. + if (model.source.replacementPending) { + model.source.replacementPending = false + model.loaded = new Set() } + model.source.reloadInFlight = false + if (model.source.ready) return + if (model.source.firstLoadFailed && !model.source.reloadRun) return + model.source.ready = true + model.source.firstLoadFailed = false + model.source.reloadRun = false + // A query in `error` stays there until its own reload(). return case `crash`: model.source.crashed = true + model.source.reloadInFlight = false + model.source.replacementPending = false model.gate = `crashed` return } @@ -4278,12 +4333,20 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { // sync() returns, so its loads in flight settle before the replay exists. // `honorsAbort`: the loader rejects a request whose signal aborts; one that // does not may still finish it later. +// `holdChildReady`: every child sync run holds markReady() until a +// `child-ready` step, so the child can still be `loading` when it reloads, +// and its reload stays in flight past the new run's first commit. async function runLazyHistory( steps: ReadonlyArray<LazyStep>, { asyncChildReady = false, honorsAbort = true, - }: { asyncChildReady?: boolean; honorsAbort?: boolean } = {}, + holdChildReady = false, + }: { + asyncChildReady?: boolean + honorsAbort?: boolean + holdChildReady?: boolean + } = {}, ) { const model: LazyModel = { gate: `loading`, @@ -4297,7 +4360,17 @@ async function runLazyHistory( obsolete: new Map(), retiredInFlight: new Map(), requested: new Map(), - source: { failure: null, stopped: null, crashed: false }, + source: { + failure: null, + stopped: null, + crashed: false, + ready: !holdChildReady, + firstLoadFailed: false, + reloadRun: false, + reloadInFlight: false, + holdsReady: holdChildReady, + replacementPending: false, + }, } // Loads in flight at the adapter, oldest first, and what the adapter saw // since the last report: each request, and which of them were aborted or @@ -4319,6 +4392,7 @@ async function runLazyHistory( let parentParams!: SyncParams let childParams!: SyncParams let forgetServed = () => {} + let heldReady: (() => void) | undefined // Loads that threw, by serial. const thrownLoads = new Set<number>() const parents = createCollection<Row, number>({ @@ -4347,7 +4421,8 @@ async function runLazyHistory( // after the run's own truncate, serves them again. const served = new Set<number>() forgetServed = () => served.clear() - if (childRuns++ > 0 && asyncChildReady) + if (holdChildReady) heldReady = () => params.markReady() + else if (childRuns++ > 0 && asyncChildReady) setTimeout(() => params.markReady(), 0) else params.markReady() return { @@ -4454,6 +4529,14 @@ async function runLazyHistory( 1, ) const load = model.pending.find((entry) => entry.serial === serial) + // A reload of a ready child completes at the new run's first commit, + // which replaces every child row. + if (succeeded && load && model.source.ready) + model.source.reloadInFlight = false + if (succeeded && load && model.source.replacementPending) { + model.source.replacementPending = false + model.loaded = new Set() + } if (load) settleLazyLoad(model, load, succeeded) else if (succeeded && model.obsolete.has(serial)) for (const key of model.obsolete.get(serial)!) model.loaded.add(key) @@ -4504,6 +4587,14 @@ async function runLazyHistory( ? loads[0] : loads.at(-1) if (pick) settle([pick], step.kind.endsWith(`-ok`)) + } else if (step.kind === `child-ready`) { + const ready = heldReady + heldReady = undefined + try { + ready?.() + } catch { + // markReady() after a failed first load throws. + } } else if (step.kind === `throw-next`) { throwNext = true } else if (step.kind === `reload`) { @@ -4511,7 +4602,9 @@ async function runLazyHistory( } else if (step.kind === `truncate`) { if (children.status !== `crashed` && !model.source.stopped) { childParams.begin() - childParams.truncate() + // Readiness stays with markReady(), so a truncate can land while + // a reload is still in flight. + childParams.truncate({ markReady: false }) childParams.commit() forgetServed() } @@ -4621,6 +4714,7 @@ describe(`lazy join demand grammar`, () => { `throw-next` as const, `reload` as const, `truncate` as const, + `child-ready` as const, `report` as const, `fail` as const, `crash` as const, @@ -4839,12 +4933,19 @@ describe(`lazy join demand grammar`, () => { asyncChildReady: true, })) fcTest.prop( - [fc.array(step, { maxLength: 10 }), fc.boolean(), fc.boolean()], + [ + fc.array(step, { maxLength: 10 }), + fc.boolean(), + fc.boolean(), + fc.boolean(), + ], oraclePropertyOptions(60, `source-health.lazy-demand`), )( `generated lazy demand histories refine the model`, - (steps, asyncChildReady, honorsAbort) => - withDeadline(runLazyHistory(steps, { asyncChildReady, honorsAbort })), + (steps, asyncChildReady, honorsAbort, holdChildReady) => + withDeadline( + runLazyHistory(steps, { asyncChildReady, honorsAbort, holdChildReady }), + ), 120_000, ) }) From 838ced70924b248019c7da532d4a8a4e160b114e Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Thu, 22 Oct 2026 21:31:22 -0600 Subject: [PATCH 103/106] fix(db): a reload starts its replay at retire only for live queries Grammar-first pass. The lazy and window grammars gain a direct subscriber with the initial state on the on-demand source, and the lazy grammar an Effect over the same join. With a reload still in flight, the direct subscriber lost its rows at the reload step: its retire-time replay settled at once and published an empty private replacement before the reload's truncate applied. An Effect buffers the same way. Only a subscription with delegated publication (a live query) now starts its replay when the reload retires the run. Every other subscription keeps its rows until the reload's first commit truncates them, and replays then. The previous rule fails the new direct-subscriber observation. Co-authored-by: Isaac <no-reply@databricks.com> --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/subscription.ts | 15 +- packages/db/src/query/live/ARCHITECTURE.md | 8 +- .../db/tests/source-health-oracle.test.ts | 129 +++++++++++++++++- 4 files changed, 140 insertions(+), 14 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index fd0d8035e5..4301290373 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 44a6da59dd..099bfbc590 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -328,14 +328,19 @@ export class CollectionSubscription /** * @internal A reload retired the sync run. Its demand detaches from the - * retired run. On-demand demand replays through the new run at once, as - * after a truncate, so a consumer of an aborted load follows that replay. - * Without such demand, the retained rows stay published until the - * reload's first commit truncates them. + * retired run. A live query's on-demand demand replays through the new run + * at once, as after a truncate, so a consumer of an aborted load follows + * that replay. Every other subscription keeps its rows published until the + * reload's first commit truncates them, and replays then. */ public retireSyncRun(): void { this.handleCollectionCleanup(true) - this.reloadReplayed = this.replaysDemand() && this.subsetDemands.length > 0 + // A subscription that buffers a private replacement would publish it + // before the reload's truncate applies; it replays at that truncate. + this.reloadReplayed = + this.options.truncateReplayPublication !== undefined && + this.replaysDemand() && + this.subsetDemands.length > 0 if (this.reloadReplayed) this.handleTruncate() } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 129cb04525..d9091e4bab 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1698,9 +1698,11 @@ every row before the top-K (a recorded limit). load or a root Collection's load that fails after initial-query readiness is an `error` on the query's own node. A lazy demand's error clears when that demand settles or is released; a root Collection's error clears when - a later load of that Collection settles. A source reload starts its - replay when it retires the sync run, as a truncate does, and the replay - decides every load it replaces: a replaced load's own outcome decides + a later load of that Collection settles. A source reload starts a live + query's replay when it retires the sync run, as a truncate does. A + direct subscriber or an Effect keeps its rows until the reload's first + commit truncates them, and replays then. The replay decides every load + it replaces: a replaced load's own outcome decides nothing, even when it settles after the replay. A load that fails while the replay is in flight fails with its own error once the replay settles. A lazy demand's error clears as soon as a later load of its diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 29d542a790..bb428ed5e1 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -3764,7 +3764,50 @@ function applyDemandModel(model: DemandModel, step: DemandStep): void { } } -async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { +// A direct subscriber with the initial state on an on-demand Collection. +// Within one Collection lifetime its applied rows equal the Collection's rows +// at every step, also while a reload's or a truncate's replay is in flight or +// after it fails. The driver answers its own subset requests at once, with +// no rows, and keeps them out of the model: it observes, it asks for nothing. +// `boundary.owns` tells the loader which requests are the subscriber's. +function watchDirectly( + collection: Collection<Row, number>, + boundary: { owns: (options: LoadSubsetOptions) => boolean }, +) { + const rows = new Map<number, number>() + const current: { subscription?: unknown } = {} + // Its requests during subscribeChanges() come before it returns. + boundary.owns = (options) => + current.subscription === undefined || + options.subscription === current.subscription + current.subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) rows.delete(change.key) + else rows.set(change.key, change.value.parentId) + } + }, + { includeInitialState: true }, + ) + return { + check: (label: string) => + expect( + [...rows].sort(([a], [b]) => a - b), + `${label}: direct subscriber rows`, + ).toEqual( + collection.toArray + .map((row): [number, number] => [row.id, row.parentId]) + .sort(([a], [b]) => a - b), + ), + stop: () => + (current.subscription as { unsubscribe: () => void }).unsubscribe(), + } +} + +async function runDemandHistory( + steps: ReadonlyArray<DemandStep>, + directSubscriber = false, +) { const model: DemandModel = { gate: `loading`, requestError: null, @@ -3781,6 +3824,7 @@ async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { } const loads: Array<ReturnType<typeof createDeferred<void>>> = [] let params!: SyncParams + const boundary = { owns: (_options: LoadSubsetOptions) => false } const source = createCollection<Row, number>({ id: `health-demand-${Math.random().toString(36).slice(2)}`, getKey: (row) => row.id, @@ -3794,6 +3838,7 @@ async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { next.markReady() return { loadSubset: (options: LoadSubsetOptions) => { + if (boundary.owns(options)) return true const load = createDeferred<void>() loads.push(load) const end = options.limit === undefined ? 99 : model.offset + 2 @@ -3819,9 +3864,11 @@ async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { ) const consoleError = console.error console.error = () => {} + let watcher: ReturnType<typeof watchDirectly> | undefined try { void query.preload().catch(() => undefined) await flushPromises() + if (directSubscriber) watcher = watchDirectly(source, boundary) requestedLoads(model, loads.length) for (const [index, step] of steps.entries()) { const label = `step ${index} ${step}` @@ -3866,6 +3913,7 @@ async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { requestedLoads(model, requested, step) expect(query.status, `${label}: query status`).toBe(model.gate) + watcher?.check(label) if (model.gate === `ready`) { expect( query.health.error?.message ?? null, @@ -3896,6 +3944,7 @@ async function runDemandHistory(steps: ReadonlyArray<DemandStep>) { } } finally { console.error = consoleError + watcher?.stop() for (const load of loads.splice(0)) load.resolve() await query.cleanup() await source.cleanup({ dependents: true }) @@ -3967,11 +4016,12 @@ describe(`on-demand demand grammar`, () => { `crash`, ) fcTest.prop( - [fc.array(step, { maxLength: 10 })], + [fc.array(step, { maxLength: 10 }), fc.boolean()], oraclePropertyOptions(60, `source-health.on-demand`), )( `generated demand histories refine the model`, - (steps) => withDeadline(runDemandHistory(steps)), + (steps, directSubscriber) => + withDeadline(runDemandHistory(steps, directSubscriber)), 120_000, ) }) @@ -4342,10 +4392,14 @@ async function runLazyHistory( asyncChildReady = false, honorsAbort = true, holdChildReady = false, + directSubscriber = false, + withEffect = false, }: { asyncChildReady?: boolean honorsAbort?: boolean holdChildReady?: boolean + directSubscriber?: boolean + withEffect?: boolean } = {}, ) { const model: LazyModel = { @@ -4393,6 +4447,8 @@ async function runLazyHistory( let childParams!: SyncParams let forgetServed = () => {} let heldReady: (() => void) | undefined + const boundary = { owns: (_options: LoadSubsetOptions) => false } + let querySubscription: unknown // Loads that threw, by serial. const thrownLoads = new Set<number>() const parents = createCollection<Row, number>({ @@ -4427,6 +4483,15 @@ async function runLazyHistory( else params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { + // The query starts first, so its subscription makes the first + // request. The direct subscriber's and the Effect's requests are + // answered at once and stay out of the model. + querySubscription ??= options.subscription + if ( + options.subscription !== querySubscription || + boundary.owns(options) + ) + return true const keys = requestedKeys(options) const serial = ++serials const thrown = throwNext @@ -4546,10 +4611,39 @@ async function runLazyHistory( else deferred.reject(new Error(`child load ${++failures} failed`)) } } + let watcher: ReturnType<typeof watchDirectly> | undefined + // An Effect over the same lazy join, with its delivered rows by key. + const effectRows = new Map< + string | number, + readonly [number, number | null] + >() + let effect: ReturnType<typeof createEffect> | undefined try { void query.preload().catch(() => undefined) await flushPromises() report() + if (directSubscriber) watcher = watchDirectly(children, boundary) + if (withEffect) { + effect = createEffect({ + query: (q) => + q + .from({ parent: parents }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ), + onBatch: (events) => { + for (const event of events) { + const row = event.value as { parent: Row; child?: Row } + if (event.type === `exit`) effectRows.delete(event.key) + else + effectRows.set(event.key, [row.parent.id, row.child?.id ?? null]) + } + }, + onSourceError: () => {}, + }) + await flushPromises() + report() + } let announcements = 0 query.on(`health:change`, () => announcements++) for (const [index, step] of steps.entries()) { @@ -4642,6 +4736,7 @@ async function runLazyHistory( deriveLazyGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) + watcher?.check(label) expect(query.health, `${label}: stable read`).toBe(query.health) if ( demandOnly && @@ -4687,10 +4782,19 @@ async function runLazyHistory( .sort((a, b) => a - b) .map((key) => [key, model.loaded.has(key) ? key * 10 : null]), ) + // An Effect over the same query that a load failure has not ended + // has received the same rows by now. + if (effect && !effect.disposed && !model.replayActive) + expect( + [...effectRows.values()].sort((a, b) => a[0] - b[0]), + `${label}: effect rows`, + ).toEqual([...rows].sort((a, b) => a[0] - b[0])) } } } finally { console.error = consoleError + watcher?.stop() + await effect?.dispose() for (const { deferred } of loads.splice(0)) deferred.resolve() await query.cleanup() await parents.cleanup() @@ -4938,13 +5042,28 @@ describe(`lazy join demand grammar`, () => { fc.boolean(), fc.boolean(), fc.boolean(), + fc.boolean(), + fc.boolean(), ], oraclePropertyOptions(60, `source-health.lazy-demand`), )( `generated lazy demand histories refine the model`, - (steps, asyncChildReady, honorsAbort, holdChildReady) => + ( + steps, + asyncChildReady, + honorsAbort, + holdChildReady, + directSubscriber, + withEffect, + ) => withDeadline( - runLazyHistory(steps, { asyncChildReady, honorsAbort, holdChildReady }), + runLazyHistory(steps, { + asyncChildReady, + honorsAbort, + holdChildReady, + directSubscriber, + withEffect, + }), ), 120_000, ) From b90fa830e892e814d874e5e42e4dee14f029742d Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Fri, 23 Oct 2026 10:46:30 -0600 Subject: [PATCH 104/106] fix(db): declare source-health reach and fix what it found Declared reach (ORC-015) in the three source-health properties found seven states their generated histories never reached at the base run count; reach prefixes now enter each one. The newly reached states found: - A canceled reload commit lost the reload's replacement when the adapter truncated in it. Reload replacement and adapter truncate are now separate flags. - A reload after a truncate let the query turn ready during the detach, before the reload's replay. The replacement stays pending across it. - A truncate replay did not clear the error of a key whose own retry was still pending. - A demand change released before it requested, so an open replay was judged with no demand in between. ARCHITECTURE.md law 9 now states that a demand change is one step, and setDemand starts its new demand before it releases the retired segments. The lazy model now encodes law 9 from its text: an open replay keeps new demand, its failures are judged at settlement against the demand held then, a replay whose participants were all released is aborted, and a replay load's success clears its key's error only with the whole replay. The replay-refinement oracle from main gains a request-error reach. Open: rare lazy and window traces around a failed replay under demand churn. The three source-health models encode the shared laws separately; the next commits unify them. Co-authored-by: Isaac <no-reply@databricks.com> --- packages/db/src/collection/state.ts | 12 +- packages/db/src/collection/subscription.ts | 10 +- packages/db/src/collection/sync.ts | 14 +- packages/db/src/query/live/ARCHITECTURE.md | 8 +- .../query/live/subset-demand-controller.ts | 110 ++--- .../db/tests/source-health-oracle.test.ts | 381 ++++++++++++++++-- 6 files changed, 440 insertions(+), 95 deletions(-) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 7d281ab571..c472563e3b 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -58,10 +58,12 @@ interface PendingSyncedTransaction< layoutChanged: boolean operations: Array<PendingSyncOperation<T, TKey>> truncate?: boolean - // Who made the transaction replace every row: a reload's first - // transaction, or the adapter's own truncate(). A reload that ends early - // drops only its own. - truncateOwner?: `reload` | `adapter` + // Why the transaction replaces every row. A reload's first transaction + // replaces them, and the adapter's own truncate() can too, in the same + // transaction. A reload that ends early drops only its own replacement; a + // canceled one moves to the run's next transaction. + reloadReplacement?: boolean + adapterTruncate?: boolean truncateMarkReady?: boolean rowMetadataWrites: Map<TKey, PendingMetadataWrite> /** The last explicit write per key, after `position` operations. */ @@ -1294,7 +1296,7 @@ export class CollectionStateManager< } // Emit truncate event so subscriptions can reset their cursor tracking state - this.emittingReloadTruncate = transaction.truncateOwner === `reload` + this.emittingReloadTruncate = transaction.reloadReplacement === true try { this._events.emit(`truncate`, { type: `truncate`, diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index e2f1be5ae3..2da7f31b54 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -333,13 +333,15 @@ export class CollectionSubscription * reload's first commit truncates them, and replays then. */ public retireSyncRun(): void { - this.handleCollectionCleanup(true) // A subscription that buffers a private replacement would publish it // before the reload's truncate applies; it replays at that truncate. this.reloadReplayed = this.options.truncateReplayPublication !== undefined && this.replaysDemand() && this.subsetDemands.length > 0 + // The replacement stays pending from the old replay to the new one, so + // nothing that runs during the detach sees a settled source. + this.handleCollectionCleanup(true, this.reloadReplayed) if (this.reloadReplayed) this.handleTruncate() } @@ -354,8 +356,12 @@ export class CollectionSubscription } /** Detach logical demand from work owned by a discarded sync run. */ - private handleCollectionCleanup(keepWaitingCallers = false): void { + private handleCollectionCleanup( + keepWaitingCallers = false, + holdReplacement = false, + ): void { this.discardTruncateReplay() + this.truncateReplacementPending = holdReplacement this.stalePublishedRows = new Map(this.publishedRows) this.pendingLoadSubsetParticipants.clear() diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index cae1c902e1..c6892471ff 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -394,7 +394,7 @@ export class CollectionSyncManager< if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() // The adapter's own truncate stays even if a reload ends early. - pendingTransaction.truncateOwner = `adapter` + pendingTransaction.adapterTruncate = true // Clear all operations from the current transaction pendingTransaction.operations = [] @@ -549,10 +549,14 @@ export class CollectionSyncManager< public dropReloadReplacement(): void { this.state.reloadTruncatePending = false const open = this.state.pendingSyncedTransactions.find( - (transaction) => - !transaction.committed && transaction.truncateOwner === `reload`, + (transaction) => !transaction.committed && transaction.reloadReplacement, ) if (!open) return + open.reloadReplacement = false + if (open.adapterTruncate) { + this.state.refreshPendingSyncedProjection() + return + } open.truncate = false // The run wrote against no rows; on the kept rows, an insert of a kept // key replaces that row. @@ -572,7 +576,7 @@ export class CollectionSyncManager< private markReloadTruncate(applied: { promise: Promise<void> }): void { this.state.reloadTruncatePending = false const transaction = this.state.pendingSyncedTransactions.at(-1)! - transaction.truncateOwner = `reload` + transaction.reloadReplacement = true transaction.truncate = true transaction.truncateMarkReady = false this.state.refreshPendingSyncedProjection() @@ -603,7 +607,7 @@ export class CollectionSyncManager< reason?: Error, ): void { this.state.cancelPendingSyncedTransaction(transaction, reason) - if (transaction.truncateOwner === `reload` && this.lifecycle.reloading) + if (transaction.reloadReplacement && this.lifecycle.reloading) this.state.reloadTruncatePending = true } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index fee1550aee..d36cb46538 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1427,7 +1427,10 @@ snapshot requests do not reopen that gate because they cannot prove the source complete; only a later successful truncate replay provides the authoritative replacement. Replay failure is scoped to the logical demand that failed. If that demand retires, its failure cannot poison a successful replacement for the -remaining demand. If the last logical demand retires, the now-unreachable source +remaining demand. A demand change is one step: the demand it starts and the +demand it retires count together, so a change that asks for data again does not +retire the last logical demand, and its new demand joins the open replay. If the +last logical demand retires, the now-unreachable source replay rejects its completion with `AbortError` and stops gating the shared graph; unrelated parent or sibling changes may then publish. A direct subscriber's replay whose current attempt has not failed instead publishes the @@ -1738,7 +1741,8 @@ every row before the top-K (a recorded limit). started by its active replay demands settles; failure keeps the prior public result and later partial source changes private until an authoritative replay succeeds. A failed replay with no remaining logical demand cannot gate other - graph work. + graph work. A demand change counts as one step, so a change that asks for + data again leaves the replay its new demand. 10. **Initial demand:** preload completes when every initially reachable demand is covered and every eager source is ready; obsolete demand does not block it, and an on-demand source's own Collection readiness does not gate it. diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index 79fed4b78d..f38d07d9f0 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -58,8 +58,10 @@ export class SubsetDemandController { settleReplay(): Array<string> { const loaded: Array<string> = [] for (const [planId, state] of this.states) { + // Every segment the subscription holds was loaded again, also one whose + // own retry is still pending. A load that threw holds no demand. for (const segment of state.segments) - if (segment.state === `failed` && !segment.thrown) + if (!segment.thrown) for (const key of segment.keys.keys()) state.failedKeys.delete(key) if (state.failedKeys.size === 0) loaded.push(planId) } @@ -114,22 +116,19 @@ export class SubsetDemandController { } this.states.set(plan.id, state) - // A failed segment is released first, so a replay judged during this - // change does not fail for the demand the change requests again. Release - // every retired segment even if subscriber code that a release runs - // throws, then report the first error. - retired.sort( - (a, b) => Number(b.state === `failed`) - Number(a.state === `failed`), - ) - runAllCallbacks( - retired.map((segment) => () => releaseSegment(subscription, segment)), - ) - if (this.states.get(plan.id) !== state) { - return { changed: false, empty: false, ready: true } - } + // Law 9: a demand change is one step. Start its new demand before it + // releases the retired segments, so an open replay keeps the new demand + // and is not judged in between. Release every retired segment even if + // subscriber code that a release runs throws, then report the first + // error. + const release = () => + runAllCallbacks( + retired.map((segment) => () => releaseSegment(subscription, segment)), + ) if (nextKeys.size === 0) { this.states.delete(plan.id) + release() return { changed: true, empty: true, ready: true } } @@ -148,47 +147,54 @@ export class SubsetDemandController { // A replacement alone decides the demand: the segments it supersedes are // released, and aborted, once it settles. let replacement: DemandSegment | undefined - if (requestedKeys.size > 0) { - const segment = createSegment(plan, requestedKeys) - if (replace) { - segment.replaces = [...segments] - replacement = segment - } - segments.push(segment) - let started: boolean - try { - started = startSegment( - subscription, - segment, - () => this.warnUnoptimized(plan), - (succeeded) => this.recordOutcome(plan.id, segment, succeeded), - ) - } catch (error) { - // A load that throws failed its keys, as one that rejects does. - segment.state = `failed` - segment.thrown = true - this.recordOutcome(plan.id, segment, false) - throw error - } - if (this.states.get(plan.id) !== state) { - return { changed: false, empty: false, ready: true } - } - if (!started) - throw new Error( - devBuild() && process.env.NODE_ENV !== `production` - ? `Subset demand snapshot did not start` - : codedMessage(159), - ) - if (replace) { - if (segment.ready instanceof Promise) { - void segment.ready.then( - () => this.finishReplacement(subscription, plan.id, segment), - () => {}, + try { + if (requestedKeys.size > 0) { + const segment = createSegment(plan, requestedKeys) + if (replace) { + segment.replaces = [...segments] + replacement = segment + } + segments.push(segment) + let started: boolean + try { + started = startSegment( + subscription, + segment, + () => this.warnUnoptimized(plan), + (succeeded) => this.recordOutcome(plan.id, segment, succeeded), ) - } else { - this.finishReplacement(subscription, plan.id, segment) + } catch (error) { + // A load that throws failed its keys, as one that rejects does. + segment.state = `failed` + segment.thrown = true + this.recordOutcome(plan.id, segment, false) + throw error + } + if (this.states.get(plan.id) !== state) { + return { changed: false, empty: false, ready: true } + } + if (!started) + throw new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `Subset demand snapshot did not start` + : codedMessage(159), + ) + if (replace) { + if (segment.ready instanceof Promise) { + void segment.ready.then( + () => this.finishReplacement(subscription, plan.id, segment), + () => {}, + ) + } else { + this.finishReplacement(subscription, plan.id, segment) + } } } + } finally { + release() + } + if (this.states.get(plan.id) !== state) { + return { changed: false, empty: false, ready: true } } const pending = (replacement ? [replacement] : state.segments) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index bb428ed5e1..a1d3a6b263 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -151,7 +151,7 @@ * Collections. Grammar exclusions are listed with the history grammar. */ import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { createCollection, findProblems } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createEffect } from '../src/query/effect.js' @@ -162,6 +162,7 @@ import { InvalidCollectionStatusTransitionError } from '../src/errors.js' import { RequestErrors } from '../src/collection/lifecycle.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { createDeferred } from '../src/deferred.js' +import { createOracleReach } from './oracle-reach.js' import { oraclePropertyOptions } from './oracle-config.js' import { flushPromises } from './utils.js' import type { Collection } from '../src/collection/index.js' @@ -1356,10 +1357,23 @@ async function withDeadline(work: Promise<void>): Promise<void> { } } +// The states the main grammar's later laws depend on (ORC-015). Only the +// generated property records them. +const historyReachStates = [ + `direct subscriber during a reload`, + `reload ends before its first commit`, + `untagged insert of a kept row`, + `retired cleanup rejects after a fresh start`, +] as const +type HistoryReach = ReturnType< + typeof createOracleReach<(typeof historyReachStates)[number]> +> + async function runHistory( shape: Shape, steps: ReadonlyArray<Step>, directSubscribers = true, + reach?: HistoryReach, ): Promise<void> { const sources = { A: createSource(`A`), B: createSource(`B`) } const { query, inner, start, size } = buildQuery( @@ -1409,6 +1423,8 @@ async function runHistory( // only subscriber. const mirrors = { A: new Map<number, number>(), B: new Map<number, number>() } const mirrored = { A: false, B: false } + // A restart happened while a retired run's cleanup was still pending. + const freshSinceRetire = { A: false, B: false } const mirrorSubscriptions: Partial< Record<SourceName, { unsubscribe: () => void }> > = {} @@ -1507,6 +1523,33 @@ async function runHistory( cleaning: model.sources[step.source].cleaning, } : undefined + if ( + reach && + `source` in step && + shapeSources[shape].includes(step.source) + ) { + const source = model.sources[step.source] + if (source.reloading && mirrored[step.source]) + reach.reach(`direct subscriber during a reload`) + if (source.reloading && (step.kind === `fail` || step.kind === `crash`)) + reach.reach(`reload ends before its first commit`) + if ( + step.kind === `write` && + step.untagged && + source.rows.has(step.key) && + !expectedBaseline(source).has(step.key) + ) + reach.reach(`untagged insert of a kept row`) + if ( + step.kind === `retired-cleanup-rejects` && + source.retiredCleanup && + freshSinceRetire[step.source] + ) + reach.reach(`retired cleanup rejects after a fresh start`) + } + if (step.kind === `restart`) + for (const name of [`A`, `B`] as const) + if (model.sources[name].retiredCleanup) freshSinceRetire[name] = true const queryEndedBefore = model.queryEnded const expected = applyModel(shape, model, step) // The write tag the model gave this step, if it wrote. @@ -1834,6 +1877,15 @@ const namedHistories: Record<string, ReadonlyArray<Step>> = { on(`ready`, `A`), on(`retired-cleanup-rejects`, `A`), ], + "a canceled reload commit with the adapter's own truncate keeps the replacement armed": + [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 1, untagged: true }, + { kind: `truncate`, source: `A`, markReady: false }, + on(`cancel`, `A`), + ], "a failed reload's open insert of a kept row replaces it": [ on(`ready`, `A`), on(`ready`, `B`), @@ -2157,19 +2209,57 @@ describe(`source failure and health oracle`, () => { .map(([kind, source]) => on(kind, source)), }, ) + // Prefixes that enter a declared reach state that random steps alone rarely + // reach at the base run count; random steps follow each one. + const reachPrefixes: ReadonlyArray<ReadonlyArray<Step>> = [ + // A reload's open insert of a row the Collection keeps. + [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload`, `A`), + { kind: `write`, source: `A`, key: 1, untagged: true }, + ], + // A retired run's cleanup that rejects after a cleanup and a fresh start. + [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`reload-cleanup-held`, `A`), + { kind: `restart` }, + on(`ready`, `A`), + on(`retired-cleanup-rejects`, `A`), + ], + ] + const historyReach = createOracleReach( + `source-health.history`, + historyReachStates, + ) + let historiesRan = false fcTest.prop( [ fc.constantFrom(...shapes), - fc.array(stepArb, { maxLength: 12 }), + fc + .tuple( + fc.option(fc.constantFrom(...reachPrefixes), { freq: 3 }), + fc.array(stepArb, { maxLength: 12 }), + ) + .map(([prefix, rest]): Array<Step> => [...(prefix ?? []), ...rest]), fc.boolean(), ], oraclePropertyOptions(60, `source-health.history`), )( `generated histories refine the model`, - (shape, steps, directSubscribers) => - withDeadline(runHistory(shape, steps, directSubscribers)), + (shape, steps, directSubscribers) => { + historiesRan = true + historyReach.beginHistory() + return withDeadline( + runHistory(shape, steps, directSubscribers, historyReach), + ) + }, 120_000, ) + afterAll(() => { + if (historiesRan) historyReach.assertReached() + }) }) // --------------------------------------------------------------------------- @@ -3804,9 +3894,20 @@ function watchDirectly( } } +// The states the window grammar's later laws depend on (ORC-015). Only the +// generated property records them. +const demandReachStates = [ + `window move waits for a replay`, + `direct subscriber during a replay`, +] as const +type DemandReach = ReturnType< + typeof createOracleReach<(typeof demandReachStates)[number]> +> + async function runDemandHistory( steps: ReadonlyArray<DemandStep>, directSubscriber = false, + reach?: DemandReach, ) { const model: DemandModel = { gate: `loading`, @@ -3873,6 +3974,10 @@ async function runDemandHistory( for (const [index, step] of steps.entries()) { const label = `step ${index} ${step}` applyDemandModel(model, step) + if (model.moveWaitsForReplay) + reach?.reach(`window move waits for a replay`) + if (directSubscriber && model.replayPending) + reach?.reach(`direct subscriber during a replay`) let requested = 0 const before = loads.length if (step === `window`) { @@ -4015,13 +4120,43 @@ describe(`on-demand demand grammar`, () => { `fail`, `crash`, ) + const demandReach = createOracleReach( + `source-health.on-demand`, + demandReachStates, + ) + let demandHistoriesRan = false + afterAll(() => { + if (demandHistoriesRan) demandReach.assertReached() + }) + // A prefix that enters a declared reach state that random steps alone + // rarely reach at the base run count; random steps follow it. + const demandReachPrefixes: ReadonlyArray<ReadonlyArray<DemandStep>> = [ + [`settle-ok`, `reload`, `window`], + [`settle-ok`, `reload`, `settle-fail`, `window`], + ] fcTest.prop( - [fc.array(step, { maxLength: 10 }), fc.boolean()], + [ + fc + .tuple( + fc.option(fc.constantFrom(...demandReachPrefixes), { freq: 2 }), + fc.array(step, { maxLength: 10 }), + ) + .map(([prefix, rest]): Array<DemandStep> => [ + ...(prefix ?? []), + ...rest, + ]), + fc.boolean(), + ], oraclePropertyOptions(60, `source-health.on-demand`), )( `generated demand histories refine the model`, - (steps, directSubscriber) => - withDeadline(runDemandHistory(steps, directSubscriber)), + (steps, directSubscriber) => { + demandHistoriesRan = true + demandReach.beginHistory() + return withDeadline( + runDemandHistory(steps, directSubscriber, demandReach), + ) + }, 120_000, ) }) @@ -4103,6 +4238,12 @@ type LazyModel = { // Keys a replay load failed for; the replay reports the failure once all // its loads settle, if the query still needs one of them then. replayFailedKeys: Set<number> + // Replay loads that failed in the current attempt, by serial; the replay + // judges them when it settles. + replayFailedLoads: Map<number, Set<number>> + // A load of the current replay attempt succeeded, and the keys it loaded. + replayLoaded: boolean + replayLoadedKeys: Set<number> // Aborted loads the current run may still finish: their rows apply. obsolete: Map<number, Set<number>> // Loads a reload retired that are still in flight at the adapter. As for a @@ -4172,6 +4313,13 @@ function settleLazyLoad( if (succeeded) { for (const key of load.keys) { model.loaded.add(key) + // A replay load's success clears its key's error only when the whole + // replay succeeds (law 16: its success clears only the errors of + // demands it loaded again). + if (load.replay) { + model.replayLoadedKeys.add(key) + continue + } // A key loaded: its failure clears, unless a newer load of it failed // and this load no longer holds the key (the query aborted it). if ( @@ -4180,7 +4328,7 @@ function settleLazyLoad( ) model.failed.delete(key) } - if (load.replay) model.replayFailed = false + if (load.replay) model.replayLoaded = true settleReplay(model) return } @@ -4194,9 +4342,10 @@ function settleLazyLoad( if (needed.length > 0) model.gate = `error` return } - if (load.replay) { - for (const key of needed) model.replayFailedKeys.add(key) - } else for (const key of needed) model.failed.set(key, load.serial) + // Law 9 judges a replay when it settles, against the demand held then: a + // failed replay load counts for each key it is then the latest load of. + if (load.replay) model.replayFailedLoads.set(load.serial, load.keys) + else for (const key of needed) model.failed.set(key, load.serial) settleReplay(model) } @@ -4219,12 +4368,36 @@ function settleReplay(model: LazyModel): void { entry.replay && (!entry.superseded || [...entry.keys].some((key) => model.parents.has(key))) if (model.pending.some(holds)) return + const settling = model.replayActive model.replayActive = false - const failed = [...model.replayFailedKeys].some((key) => - model.parents.has(key), + for (const [serial, keys] of model.replayFailedLoads) + for (const key of keys) + if (model.parents.has(key) && latestLoadFor(model, key) === serial) + model.replayFailedKeys.add(key) + model.replayFailedLoads = new Map() + // ARCHITECTURE.md law 9: replay failure is scoped to the logical demand + // that failed. A failed key stays failed while the query holds it; once + // the query retires it, its failure cannot poison a successful + // replacement for the remaining demand. + model.replayFailedKeys = new Set( + [...model.replayFailedKeys].filter((key) => model.parents.has(key)), ) - model.replayFailedKeys = new Set() - if (!failed) return + const failed = model.replayFailedKeys.size > 0 + const loadedSome = model.replayLoaded + const loadedKeys = model.replayLoadedKeys + model.replayLoaded = false + model.replayLoadedKeys = new Set() + if (!failed) { + // The replacement succeeded: its request error clears, and so do the + // errors of the keys it loaded again. A replay whose participants were + // all released, with none loaded, is aborted instead (law 9: the last + // logical demand retired), and its error stays. + if (settling && loadedSome) { + model.replayFailed = false + for (const key of loadedKeys) model.failed.delete(key) + } + return + } if (model.gate === `loading`) model.gate = `error` else model.replayFailed = true } @@ -4306,6 +4479,7 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { if (!fromReplay) model.retiredInFlight.set(serial, keys) // A replay this reload discards reports nothing. model.replayFailedKeys = new Set() + model.replayFailedLoads = new Map() model.pending = [] model.obsolete = new Map() // The new run's first commit replaces every child row. Without a held @@ -4321,6 +4495,14 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { // and its rows apply if it succeeds. A failed replay's error stays // until a later replay succeeds. if (model.source.crashed || model.source.stopped) return + // Before a reload's first commit, the adapter's truncate commits in + // the reload's first transaction: it is the replacement the reload + // already replays, and no replay row has applied yet. + if (model.source.replacementPending) { + model.source.replacementPending = false + model.loaded = new Set() + return + } for (const load of model.pending) { if (load.replay) load.superseded = true else { @@ -4329,6 +4511,7 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { } } model.replayFailedKeys = new Set() + model.replayFailedLoads = new Map() model.pending = model.pending.filter((load) => load.superseded) model.requested = new Map() model.loaded = new Set() @@ -4379,6 +4562,21 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { } } +// The states the lazy grammar's later laws depend on (ORC-015). Only the +// generated property records them. +const lazyReachStates = [ + `reload during the first load`, + `truncate while a reload is in flight`, + `replay load fails`, + `superseded replay load`, + `direct subscriber during a replay`, + `effect compared at a quiet checkpoint`, + `request error shown`, +] as const +type LazyReach = ReturnType< + typeof createOracleReach<(typeof lazyReachStates)[number]> +> + // `asyncChildReady`: the child Collection's reload calls markReady() after // sync() returns, so its loads in flight settle before the replay exists. // `honorsAbort`: the loader rejects a request whose signal aborts; one that @@ -4394,12 +4592,14 @@ async function runLazyHistory( holdChildReady = false, directSubscriber = false, withEffect = false, + reach, }: { asyncChildReady?: boolean honorsAbort?: boolean holdChildReady?: boolean directSubscriber?: boolean withEffect?: boolean + reach?: LazyReach } = {}, ) { const model: LazyModel = { @@ -4411,6 +4611,9 @@ async function runLazyHistory( replayFailed: false, pending: [], replayFailedKeys: new Set(), + replayLoaded: false, + replayLoadedKeys: new Set(), + replayFailedLoads: new Map(), obsolete: new Map(), retiredInFlight: new Map(), requested: new Map(), @@ -4553,11 +4756,9 @@ async function runLazyHistory( model.pending = model.pending.filter((load) => !retired(load)) for (const serial of aborted) model.requested.delete(serial) } - // A demand change releases the loads it retires before it requests new - // ones, so releasing a replay's last pending load settles the replay - // first. - retireAborted() - if (!replayMade) settleReplay(model) + // Law 9: a demand change is one step. The loads it starts join an open + // replay before the loads it retires stop counting, so the replay settles + // once, after the whole change. // A request a new demand makes during a replay joins the replay, but one // that throws fails at once; one the replay makes fails with the replay. // Every request is recorded before a thrown one settles, so the replay's @@ -4566,12 +4767,17 @@ async function runLazyHistory( const load: LazyLoad = { serial: request.serial, keys: request.keys, - replay: model.replayActive && (replayMade || !request.thrown), + // Law 9: new demand joins the private replacement of an open replay, + // also one whose current attempt failed. + replay: + (model.replayActive || model.replayFailed) && + (replayMade || !request.thrown), fromReplay: replayMade, superseded: false, } model.requested.set(load.serial, load.keys) model.pending.push(load) + if (load.replay) model.replayActive = true return { load, thrown: request.thrown } }) for (const { load, thrown } of reported) @@ -4659,6 +4865,12 @@ async function runLazyHistory( // Open (see the request error check below): a demand change during a // replay can show a request error and clear it at once. const replayBefore = model.replayActive + if (step.kind === `reload` && model.gate === `loading`) + reach?.reach(`reload during the first load`) + if (step.kind === `truncate` && model.source.reloadInFlight) + reach?.reach(`truncate while a reload is in flight`) + const truncateIsReloadCommit = + step.kind === `truncate` && model.source.replacementPending applyLazyModel(model, step) if (step.kind === `parent` || step.kind === `unparent`) { parentParams.begin() @@ -4722,6 +4934,7 @@ async function runLazyHistory( // request for every key the query still needs. if ( step.kind === `truncate` && + !truncateIsReloadCommit && (model.gate === `ready` || model.gate === `loading`) && !model.source.stopped ) @@ -4737,6 +4950,17 @@ async function runLazyHistory( expect(query.status, `${label}: query status`).toBe(model.gate) watcher?.check(label) + if (watcher && model.replayActive) + reach?.reach(`direct subscriber during a replay`) + if (model.replayFailedKeys.size > 0 || model.replayFailed) + reach?.reach(`replay load fails`) + if (model.pending.some((load) => load.superseded)) + reach?.reach(`superseded replay load`) + if ( + model.gate === `ready` && + (model.failed.size > 0 || model.replayFailed) + ) + reach?.reach(`request error shown`) expect(query.health, `${label}: stable read`).toBe(query.health) if ( demandOnly && @@ -4784,6 +5008,8 @@ async function runLazyHistory( ) // An Effect over the same query that a load failure has not ended // has received the same rows by now. + if (effect && !effect.disposed && !model.replayActive) + reach?.reach(`effect compared at a quiet checkpoint`) if (effect && !effect.disposed && !model.replayActive) expect( [...effectRows.values()].sort((a, b) => a[0] - b[0]), @@ -4927,7 +5153,7 @@ describe(`lazy join demand grammar`, () => { { kind: `reload` }, { kind: `settle-ok` }, ])) - it(`releasing a replay's last pending load settles the replay`, () => + it(`a demand change that asks for a failed key again keeps its replay open`, () => runLazyHistory( [ { kind: `reload` }, @@ -5021,6 +5247,55 @@ describe(`lazy join demand grammar`, () => { ], { honorsAbort: false }, )) + it(`a reload after a truncate keeps the first load waiting for its replay`, () => + runLazyHistory( + [ + { kind: `parent`, key: 3 }, + { kind: `settle-last-ok` }, + { kind: `truncate` }, + { kind: `unparent`, key: 1 }, + { kind: `reload` }, + ], + { honorsAbort: false }, + )) + it(`a truncate replay clears the error of a key whose retry is still pending`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `truncate` }, + { kind: `settle-last-ok` }, + ], + { honorsAbort: false }, + )) + it(`a failed replay succeeds for the demand left after its failed demand retires`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `unparent`, key: 1 }, + { kind: `settle-ok` }, + { kind: `settle-fail` }, + ], + { honorsAbort: false }, + )) + it(`a demand change's replacement keeps a reload's replay open in the first load`, () => + runLazyHistory( + [ + { kind: `reload` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `parent`, key: 2 }, + { kind: `settle-first-fail` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 3 }, + ], + { holdChildReady: true }, + )) it(`a first load waits for a source reload's whole replay`, () => runLazyHistory( [ @@ -5036,9 +5311,50 @@ describe(`lazy join demand grammar`, () => { runLazyHistory([{ kind: `reload` }, { kind: `settle-ok` }], { asyncChildReady: true, })) + const lazyReach = createOracleReach( + `source-health.lazy-demand`, + lazyReachStates, + ) + let lazyHistoriesRan = false + afterAll(() => { + if (lazyHistoriesRan) lazyReach.assertReached() + }) + // Prefixes that enter a declared reach state that random steps alone rarely + // reach at the base run count, with the driver options the state needs. + // Random steps follow each one. + const lazyReachPrefixes: ReadonlyArray<{ + steps: ReadonlyArray<LazyStep> + holdChildReady?: boolean + honorsAbort?: boolean + }> = [ + { steps: [{ kind: `reload` }, { kind: `truncate` }], holdChildReady: true }, + { + steps: [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + ], + }, + { + steps: [{ kind: `truncate` }, { kind: `truncate` }], + honorsAbort: false, + }, + { + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + ], + }, + ] fcTest.prop( [ - fc.array(step, { maxLength: 10 }), + fc + .tuple( + fc.option(fc.constantFrom(...lazyReachPrefixes), { freq: 2 }), + fc.array(step, { maxLength: 10 }), + ) + .map(([prefix, rest]) => ({ prefix, rest })), fc.boolean(), fc.boolean(), fc.boolean(), @@ -5049,22 +5365,29 @@ describe(`lazy join demand grammar`, () => { )( `generated lazy demand histories refine the model`, ( - steps, + { prefix, rest }, asyncChildReady, - honorsAbort, - holdChildReady, + generatedHonorsAbort, + generatedHoldChildReady, directSubscriber, withEffect, - ) => - withDeadline( + ) => { + const steps = [...(prefix?.steps ?? []), ...rest] + const honorsAbort = prefix?.honorsAbort ?? generatedHonorsAbort + const holdChildReady = prefix?.holdChildReady ?? generatedHoldChildReady + lazyHistoriesRan = true + lazyReach.beginHistory() + return withDeadline( runLazyHistory(steps, { asyncChildReady, honorsAbort, holdChildReady, directSubscriber, withEffect, + reach: lazyReach, }), - ), + ) + }, 120_000, ) }) From 9c35e0b37b0b9a39df282720f1b6d7d22b5dcb58 Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Fri, 23 Oct 2026 14:38:18 -0600 Subject: [PATCH 105/106] test(db): unify the source-health models and fix the replay defects they found The three source-health properties now share one model: the main grammar's source layer drives the on-demand Collection of the window and lazy grammars, and one demand layer (ARCHITECTURE laws 9, 10, 16) judges the window's single demand and the lazy join's per-parent keys. The lazy grammar now checks the request error at every step, including during and after a replay, and both grammars check that every held key stays covered. Production fixes, each RED on the code before it: - A demand parked behind a failed first load joins the open replay. - A failed replay with no acquisition left stays open while the query still holds demand (subscription asks the query through holdsDemand). - A load that a replay replaced decides nothing when it succeeds late. - A held replacement that loaded decides the demands it replaces. - A replay's success keeps the error of a key whose later load threw. - A cascade cleanup cancels a query reload that waits for its sources. Law: ARCHITECTURE.md laws 9, 10, 16; glossary request error (now agrees with law 9). Reach: lazy-demand declares 9 states, including coverage checked during an open replay and parked demand during a replay. RED/GREEN: each fix has a pinned history that fails on the parent code. Mutant: seven hostile mutants each fail the oracle. Limits: a child whose first load failed parks demand without a request; the model cannot see parked demand, and the laws are silent on whether it decides its keys. Open in the coverage map. Co-authored-by: Isaac <no-reply@databricks.com> --- docs/contributing/glossary.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/subscription.ts | 87 +- .../query/live/collection-config-builder.ts | 6 +- .../src/query/live/collection-subscriber.ts | 1 + .../query/live/subset-demand-controller.ts | 40 +- packages/db/src/types.ts | 2 + .../db/tests/source-health-oracle.test.ts | 1890 +++++++++++------ 8 files changed, 1360 insertions(+), 670 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 8558f59cc9..19c11c46e1 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -73,7 +73,7 @@ until cleanup ends the run. | crash | Moving a Collection to status `crashed` (`isCrashed`) from any active status, because it can no longer serve reads or admit writes. Core uses it for IndexedDB closure, terminal local persistence failures, a throwing sync function, and internal invariant failures. It cannot be recovered in place: nothing restarts it and `reload()` rejects. Only cleanup leaves it, and cleanup discards all of the Collection's state, so a later start is a fresh start. After a crash, the sync adapter's status calls (`markReady()`, `fail()`, `reportError()`, `reportHealthy()`) do nothing. Sync data that the run commits still applies, because closure keeps accepted sync obligations such as the confirmation of a write admitted before it. | Loading error, sync error, or cleanup. | | sync error | A problem after Collection readiness on a source's health node, from the sync adapter: a failure reason (`reportError()`, still trying; `reportHealthy()` clears it) or an error (`fail()`, stopped; it stays for the sync run, and every later report throws). A live query's `isSyncError` is `true` when it is `ready` and its tree has any problem. It never changes status or rows. Cleanup resets it. | Loading error or subscription status. | | failure reason | A node's `failureReason`: the latest problem while the sync adapter keeps trying, after TanStack Query's `failureReason`. | An error or a retry count. | -| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. A source's reload whose replay cannot load the query's data again is also a request error, until a later replay succeeds. | Sync error or request data. | +| request error | A rejected subset load for demand that a live query or Effect started after its initial-query readiness, shown as the `error` on that query's own node. The source Collection's node does not change. The public name stays general on purpose: an application sees a failed request for more data, not the subset-load mechanism. It clears when a later subset load for that demand fulfills, including one that a source's reload or the query's own reload issues, or when the demand retires. A source's reload whose replay cannot load the query's data again is also a request error. It clears when the replay succeeds for the demand that remains: a replay failure belongs to the logical demand that failed (ARCHITECTURE law 9), so when that demand retires, the rest of the replay's loads decide whether the error stays. | Sync error or request data. | | health tree | The `health` value of a Collection, live query, or Effect: one node for a Collection; for a query, one health node per query alias, in query order, fixed by the query definition. It keeps stable object identity while its content is unchanged, and `health:change` announces a change. | An error list. | | health node | One `HealthNode` in a health tree, for a source Collection, a nested live-query Collection, or one include. It shows at most one problem, `failureReason` or `error`. A nested live-query node carries its own health tree in `sources`. | Bucket or Collection facade. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index eedc239c54..7a4aa09a8c 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. Open: a demand change around a source reload's replay can clear the query's request error before the failed key loads again. The first recorded trace (remove and re-add parent 1, its load fails, reload the child source, add parent 3) now passes, because a reload starts its replay when it retires the sync run. A remaining trace: after the query is ready, reload the child source, add parent 2, its load fails, the replay's other load succeeds, then remove parent 1; the demand change releases every demand, so the failed replay reopens publication and its error clears while the retry of key 2 is still in flight. The maintainer deferred a fix, so the lazy demand grammar does not check the request error during or after a replay. In the same class, a demand change during a replay can release every demand before it requests the failed key again, so the subscription reports the empty replay as a success: the request error shows and clears in one step, and the grammar does not check identity or announcements for a demand change made during a replay. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay; a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted decides nothing when it finishes; a held replacement that loaded decides the demands it replaces; a replay's success keeps the error of a key whose later load threw; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady` option does nothing on a ready Collection, also after `fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()` is outside the grammars; a window move with no request is served from loaded rows. Open: a child whose first load failed parks new demand without a request, and the lazy model cannot see a parked replacement, so a demand change there followed by a failed load of the replaced segment can show no request error where the model expects one (rare at 30x; needs a maintainer decision on whether parked demand decides its keys). A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 2da7f31b54..031959fc0c 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -98,6 +98,9 @@ type TruncateReplayPublicationControl = Readonly<{ // The replay could not load every active demand again; a live query // reports it on its node. fail?: (error: Error) => void + // The live query still holds logical demand, even when no acquisition of + // it is left here, such as one whose load threw. + holdsDemand?: () => boolean }> type TruncatePublicationState = { @@ -119,6 +122,9 @@ type SubsetDemand = { acquisition: SubsetAcquisitionRecord acquisitionState: `starting` | `active` | `detached` initialResult?: Deferred<void> + // The demands this one replaces for its owner: their replay failures no + // longer count once it loads (law 10: the latest load decides). + replaces?: Array<SubsetDemand> } type TruncateReplayAttempt = { @@ -673,9 +679,17 @@ export class CollectionSubscription private checkTruncateReplayComplete(replayState: TruncateReplayState): void { if (this.truncateReplayState !== replayState) return if (replayState.pendingSetups > 0 || replayState.pending.size > 0) return - - const activeFailure = [...replayState.failures].find(([demand]) => - this.subsetDemands.includes(demand), + if (this.keepsFailedReplayOpen(replayState)) return + + // A held replacement that loaded decides the demands it replaces. + const activeFailure = [...replayState.failures].find( + ([demand]) => + this.subsetDemands.includes(demand) && + !this.subsetDemands.some( + (replacement) => + replacement.replaces?.includes(demand) && + !replayState.failures.has(replacement), + ), ) try { if (activeFailure) { @@ -1098,6 +1112,10 @@ export class CollectionSubscription }, acquisitionState: `starting`, } + // A demand that starts during an open replay joins it, parked or not (law + // 9: a new demand joins the open replay). + const replayState = this.truncateReplayState + const replayAttempt = replayState?.currentAttempt if ( this.collection.status === `cleaned-up` || // A deferring subscription starts no acquisition attempt until it resumes. @@ -1123,12 +1141,17 @@ export class CollectionSubscription demand.initialResult = undefined } void initialResult.promise.then(finish, finish) + if (replayState && replayAttempt) + this.trackTruncateReplayParticipant( + replayState, + replayAttempt, + demand, + initialResult.promise, + ) return { demand, result: initialResult.promise, started: false } } const acquisition = this.createSubsetAcquisitionRecord(demand) demand.acquisition = acquisition - const replayState = this.truncateReplayState - const replayAttempt = replayState?.currentAttempt const syncRunGeneration = this.collection._sync.getSyncRunGeneration() // Reentrant release must see the exact acquisition before adapter work // starts. A genuine load throw removes this tentative logical owner below. @@ -1372,16 +1395,42 @@ export class CollectionSubscription /** Release one exact subset request while keeping the subscription alive. */ releaseSnapshot(where: BasicExpression<boolean>): void { - const index = this.subsetDemands.findIndex( - (demand) => - demand.requestOptions.where === where || - this.requestedSubsetWhere.get(demand.requestOptions) === where, + const index = this.subsetDemands.findIndex((demand) => + this.requestedWhere(demand, where), ) if (index === -1) return this.releaseDemandAt(index) } + /** + * @internal The snapshot for `where` replaces those for `replaced` for + * their owner, which releases them once it applies. Until then they keep + * their older work in the replay. + */ + replaceSnapshots( + where: BasicExpression<boolean>, + replaced: Array<BasicExpression<boolean>>, + ): void { + const replacement = this.subsetDemands.find((demand) => + this.requestedWhere(demand, where), + ) + if (replacement) + replacement.replaces = this.subsetDemands.filter((demand) => + replaced.some((other) => this.requestedWhere(demand, other)), + ) + } + + private requestedWhere( + demand: SubsetDemand, + where: BasicExpression<boolean>, + ): boolean { + return ( + demand.requestOptions.where === where || + this.requestedSubsetWhere.get(demand.requestOptions) === where + ) + } + private releaseDemand( demand: SubsetDemand, primaryFailure?: { error: unknown }, @@ -1439,7 +1488,11 @@ export class CollectionSubscription */ private retireEmptyReplay(): void { const replayState = this.truncateReplayState - if (this.subsetDemands.length !== 0 || !replayState) { + if ( + this.subsetDemands.length !== 0 || + !replayState || + this.keepsFailedReplayOpen(replayState) + ) { return } if (replayState.privateRows && !replayState.failed) { @@ -1452,6 +1505,20 @@ export class CollectionSubscription this.options.truncateReplayPublication?.succeed() } + /** + * A failed replay whose acquisitions are all gone loaded nothing more. While + * the live query still holds logical demand, it cannot succeed: it stays + * open with its error, and new demand joins it (law 9: replacing an + * acquisition does not release its owner). + */ + private keepsFailedReplayOpen(replayState: TruncateReplayState): boolean { + return ( + replayState.failed && + this.subsetDemands.length === 0 && + this.options.truncateReplayPublication?.holdsDemand?.() === true + ) + } + /** Read source-derived ordered rows without starting demand. */ readOrderedSnapshot( options: LoadSubsetOptions, diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index e21ac5b89f..932b4a81d5 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -152,10 +152,12 @@ export class CollectionConfigBuilder< all.indexOf(collection) === index && findProblems(collection.health).some((node) => node.error !== null), ) + // Only a query that was not running starts: a cleanup that ends it during + // the reload cancels the reload, as the app ended it. + const ended = query?.status === `idle` || query?.status === `cleaned-up` await Promise.all(failed.map((collection) => collection.reload())) // Its preload is what lets a live query start its sources. - if (query?.status === `idle` || query?.status === `cleaned-up`) - return query.preload() + if (ended) return query.preload() // The query reloads itself after its sources when its own node shows an // error (a failed first load or a request error); a reload of it already // in flight settles with that run. diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 65a275ab43..2085dd6872 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -455,6 +455,7 @@ export class CollectionSubscriber< ) this.collectionConfigBuilder.failReplay(this.sourceId, error) }, + holdsDemand: () => this.demand.holdsDemand(), } } diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index f38d07d9f0..51d38a8d48 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -59,15 +59,26 @@ export class SubsetDemandController { const loaded: Array<string> = [] for (const [planId, state] of this.states) { // Every segment the subscription holds was loaded again, also one whose - // own retry is still pending. A load that threw holds no demand. + // own retry is still pending. A load that threw holds no demand, and + // as the latest load of its keys it still decides them (law 10). + const thrown = new Set( + state.segments.flatMap((segment) => + segment.thrown ? [...segment.keys.keys()] : [], + ), + ) for (const segment of state.segments) - if (!segment.thrown) - for (const key of segment.keys.keys()) state.failedKeys.delete(key) + for (const key of segment.keys.keys()) + if (!thrown.has(key)) state.failedKeys.delete(key) if (state.failedKeys.size === 0) loaded.push(planId) } return loaded } + /** Whether any plan still holds demand. */ + holdsDemand(): boolean { + return this.states.size > 0 + } + /** Whether the plan still needs a key whose latest load failed. */ hasFailedKeys(planId: string): boolean { return (this.states.get(planId)?.failedKeys.size ?? 0) > 0 @@ -180,6 +191,10 @@ export class SubsetDemandController { : codedMessage(159), ) if (replace) { + subscription.replaceSnapshots( + segment.where, + (segment.replaces ?? []).map((replaced) => replaced.where), + ) if (segment.ready instanceof Promise) { void segment.ready.then( () => this.finishReplacement(subscription, plan.id, segment), @@ -370,18 +385,21 @@ function startSegment( } // A truncate's or a reload's replay replaces a load that it aborts: the replay -// decides it, and its own late failure decides nothing. A load that fails -// while the replay is in flight fails with its own error once the replay -// settles. +// decides it, and the load's own late outcome decides nothing. A load that +// fails while the replay is in flight fails with its own error once the +// replay settles. function followReplay( subscription: CollectionSubscription, ready: Promise<unknown>, ): Promise<void> { const replacements = subscription.replacementCount + const replaced = () => { + const replay = subscription.pendingTruncateReplacement + return replay && followReplay(subscription, replay) + } const decide = (error: unknown): Promise<void> | undefined => { + if (replacements !== subscription.replacementCount) return replaced() const replay = subscription.pendingTruncateReplacement - if (replacements !== subscription.replacementCount) - return replay && followReplay(subscription, replay) if (!replay) throw error // The load joined the replay: its own failure stands once the replay // settles, unless a newer replay replaced it meanwhile. @@ -390,7 +408,11 @@ function followReplay( () => decide(error), ) } - return ready.then(undefined, decide) + return ready.then( + () => + replacements === subscription.replacementCount ? undefined : replaced(), + decide, + ) } function releaseSegment( diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index b98679a067..c440c3e08d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1267,6 +1267,8 @@ export interface SubscribeChangesOptions< readonly succeed: () => void /** The replay could not load every active demand again. */ readonly fail?: (error: Error) => void + /** The live query still holds logical demand. */ + readonly holdsDemand?: () => boolean } } diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index a1d3a6b263..01145dcdfa 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -63,6 +63,15 @@ * failed first load before the gate, ends it. It builds the expected tree * and rows from those facts. It does not read production state. * + * The model has three layers, and every grammar in this file uses the same + * first two. The source layer (`applySourceStep`) is one Collection, as + * above. The demand layer (see "Demand on an on-demand Collection") is the + * data a live query asked an on-demand Collection to load, with its loads, + * replays, and request errors. The projections derive what each grammar + * observes: the six shapes of the main grammar over two eager Collections, + * the window of an ordered query over an on-demand Collection, and a lazy + * join over an eager parent and an on-demand child. + * * ## The history grammar * * Steps act on Collection `A` or `B`: `ready` writes row 1 during a first @@ -140,11 +149,11 @@ * The main grammar's Collections are eager, so a live query cannot become * ready while a Collection is still loading; a focused lane enumerates an * on-demand Collection that fails its first load after the query is ready. - * The demand grammars settle all pending loads at once (the overlapping-load - * lane owns their order). The lazy demand grammar does not yet check the - * request error while a source reload's replay is in flight or after one - * failed: a demand change during the replay can clear the error before its - * key loads again, which stays open (the maintainer deferred a fix). Per-row child Collections are not + * The window grammar settles all pending loads at once, and the lazy grammar + * settles all, the oldest, or the newest (the overlapping-load lane owns + * other orders). The model does not see a demand parked behind a child whose + * first load failed: production makes no request for it, and the laws do not + * say whether it decides its keys (open in the coverage map). Per-row child Collections are not * in the tree; an include's node shows its source. Pooled histories do not * restart or clean up: a pooled view ends for good when its Collection is * cleaned up. The ordering check reads the observed query only, not its @@ -405,6 +414,15 @@ function applyCleanup( return } if (source.cleaning || source.gate === `cleaned-up`) return + // A Collection crashed after its cleanup has no sync run since: there is + // no adapter cleanup to wait for, so the cleanup settles at once and ends + // the crash. + if (source.retired) { + source.gate = `cleaned-up` + source.cause = null + deriveQueries(shape, model) + return + } Object.assign(source, { cleaning: true, retired: true, @@ -416,9 +434,19 @@ function applyCleanup( open: null, armed: false, }) - if (!model.queryDetached) model.queryEnded = true - model.selfReloads = [] + // A cascade that ends a query cancels its pending reload: the app ended + // it. A query that had already ended keeps its pending start, and a query + // the cascade does not reach keeps its reload. + if (!model.queryDetached && !model.queryEnded) { + model.queryEnded = true + model.selfReloads = model.selfReloads.filter( + (pending) => pending.target === `inner`, + ) + } if (name === `A`) { + model.selfReloads = model.selfReloads.filter( + (pending) => pending.target !== `inner`, + ) model.innerEnded = true if (model.effect.live) model.effect.live = false } @@ -435,7 +463,7 @@ function expectedBaseline(source: SourceModel): Rows { return new Map([...source.rows, ...(source.open?.writes ?? [])]) } -function isReload(kind: Step[`kind`]): boolean { +function isReload(kind: string): boolean { return ( kind === `reload` || kind === `reload-cleanup-fails` || @@ -462,11 +490,11 @@ function crashSource(source: SourceModel, cause: string): void { // The adapter writes an update for a key in its baseline, and an insert for // any other key. function writeRow( - model: Model, + counter: { serial: number }, source: SourceModel, - step: Extract<Step, { kind: `write` }>, + step: Extract<SourceAction, { kind: `write` }>, ): void { - const tag = ++model.serial + const tag = ++counter.serial const previous = expectedBaseline(source).get(step.key) openTransaction(source).writes.set( step.key, @@ -511,7 +539,16 @@ function commitOpen(source: SourceModel): `ok` | `throws` | `bad-transition` { completeReload(source) return `ok` } - if (!truncate || !marksReady || source.gate === `crashed`) return `ok` + // A truncate's markReady option makes a Collection that is not ready yet + // ready; on a ready one it does nothing, also after fail() (law-silent; + // recorded in the coverage map). + if ( + !truncate || + !marksReady || + source.gate === `crashed` || + source.gate === `ready` + ) + return `ok` if (source.reloading) completeReload(source) else if (source.gate === `error`) return `bad-transition` else if (source.stopped) return `throws` @@ -528,8 +565,8 @@ const shapeSources: Record<Shape, ReadonlyArray<SourceName>> = { 'pooled-nested': [`A`], } -function freshModel(): Model { - const source = (): SourceModel => ({ +function freshSource(): SourceModel { + return { gate: `loading`, cause: null, reloading: false, @@ -542,9 +579,12 @@ function freshModel(): Model { rows: new Map(), open: null, armed: false, - }) + } +} + +function freshModel(): Model { return { - sources: { A: source(), B: source() }, + sources: { A: freshSource(), B: freshSource() }, query: { gate: `loading`, cause: null, reloading: false }, inner: { gate: `loading`, cause: null, reloading: false }, serial: 0, @@ -742,7 +782,42 @@ function applyModel( } // A step on a source this shape does not read has no effect. if (!shapeSources[shape].includes(step.source)) return `ok` - const source = model.sources[step.source] + const { outcome, settles } = applySourceStep( + model, + model.sources[step.source], + step.source, + // The cleanup steps returned above; TypeScript does not narrow their + // two-valued kind. + step as Extract<Step, SourceAction>, + ) + deriveQueries(shape, model) + if (settles) settleSelfReloads(shape, model) + deriveEffect(model) + return outcome +} + +// A source action without the Collection it acts on. `mark-ready` is the +// adapter's markReady() without a write; the on-demand grammars use it. +type SourceAction = + | { kind: SourceStepKind | `mark-ready` } + | { kind: `write`; key: RowKey; untagged?: boolean } + | { kind: `commit` } + | { kind: `truncate`; markReady?: boolean } + +/** + * The source layer: one sync adapter call (or crash, or reload) on one + * Collection. Every grammar in this file drives its Collections through it, + * so a law about a Collection's status, node, reload, or rows is written + * once. `label` names the Collection in its problem messages. `settles` is + * false when the call changed only a retired or crashed run, so a query's + * pending reload has nothing new to settle. + */ +function applySourceStep( + counter: { serial: number }, + source: SourceModel, + label: string, + step: SourceAction, +): { outcome: `ok` | `throws` | `bad-transition`; settles: boolean } { let outcome: `ok` | `throws` | `bad-transition` = `ok` // After a crash, status calls do nothing and a reload rejects. After // fail(), adapter calls throw until a reload starts a new sync run. @@ -756,9 +831,9 @@ function applyModel( if (source.retired) { const crashCause = step.kind === `crash` - ? `${step.source} crashed` + ? `${label} crashed` : step.kind === `retired-cleanup-rejects` && source.retiredCleanup - ? `${step.source} cleanup failed` + ? `${label} cleanup failed` : null if (step.kind === `retired-cleanup-rejects`) source.retiredCleanup = false if (crashCause) { @@ -768,19 +843,18 @@ function applyModel( source.cause = crashCause } } else if (isReload(step.kind)) { - if (source.cleaning || source.gate === `crashed`) return `throws` + if (source.cleaning || source.gate === `crashed`) + return { outcome: `throws`, settles: false } restartSource(source) } - deriveQueries(shape, model) - deriveEffect(model) - return `ok` + return { outcome: `ok`, settles: false } } if (step.kind !== `crash` && source.gate === `crashed`) { // A crash ends the status calls. Data the run commits still applies: // closure keeps accepted sync obligations, such as the confirmation of // a write admitted before it (the IndexedDB retirement oracle owns that // law). - if (step.kind === `write`) writeRow(model, source, step) + if (step.kind === `write`) writeRow(counter, source, step) if (step.kind === `commit`) commitOpen(source) if (step.kind === `cancel`) source.open = null if (step.kind === `truncate`) { @@ -789,28 +863,26 @@ function applyModel( open.truncate = true open.adapter = true } - deriveQueries(shape, model) - deriveEffect(model) if (step.kind === `retired-cleanup-rejects`) source.retiredCleanup = false - return isReload(step.kind) ? `throws` : outcome + return { outcome: isReload(step.kind) ? `throws` : outcome, settles: false } } switch (step.kind) { case `ready`: // During a first load or a reload, the run writes row 1 and commits // before markReady(), as a first load does. if (source.gate === `loading` || source.reloading) { - openTransaction(source).writes.set(1, ++model.serial) + openTransaction(source).writes.set(1, ++counter.serial) // A commit that throws ends the step before markReady(). outcome = commitOpen(source) if (outcome !== `ok`) break } - if (source.reloading) completeReload(source) - else if (source.gate === `error`) outcome = `bad-transition` - else if (stopped) outcome = `throws` - else source.gate = `ready` + outcome = markSourceReady(source) + break + case `mark-ready`: + outcome = markSourceReady(source) break case `write`: - writeRow(model, source, step) + writeRow(counter, source, step) break case `commit`: outcome = commitOpen(source) @@ -841,7 +913,7 @@ function applyModel( // ends with it. if (!source.retiredCleanup) break source.retiredCleanup = false - crashSource(source, `${step.source} cleanup failed`) + crashSource(source, `${label} cleanup failed`) break case `cancel`: { // A commit canceled by its signal never happened: its writes are gone, @@ -859,7 +931,7 @@ function applyModel( // already dropped the retired run's open transaction. source.open = null source.gate = `crashed` - source.cause = `${step.source} cleanup failed` + source.cause = `${label} cleanup failed` source.failure = null source.stopped = null source.reloading = false @@ -880,14 +952,14 @@ function applyModel( if (outcome === `throws`) break if (source.gate !== `ready`) { source.gate = `error` - source.cause = `${step.source} gave up` + source.cause = `${label} gave up` source.reloading = false } else { source.reloading = false source.failure = null source.stopped = { - message: `${step.source} gave up`, - serial: ++model.serial, + message: `${label} gave up`, + serial: ++counter.serial, } } break @@ -895,8 +967,8 @@ function applyModel( if (stopped || source.gate !== `ready`) outcome = `throws` else { source.failure = { - message: `${step.source} problem`, - serial: ++model.serial, + message: `${label} problem`, + serial: ++counter.serial, } source.failureFromReload = source.reloading } @@ -909,13 +981,45 @@ function applyModel( } break case `crash`: - crashSource(source, `${step.source} crashed`) + crashSource(source, `${label} crashed`) break } - deriveQueries(shape, model) - settleSelfReloads(shape, model) - deriveEffect(model) - return outcome + return { outcome, settles: true } +} + +// markReady() of the current sync run. A reload completes at it when its run +// has not committed yet, and its replacement then has no rows: the rows from +// before the reload go. After a failed first load or fail() it breaks the +// adapter contract. +function markSourceReady( + source: SourceModel, +): `ok` | `throws` | `bad-transition` { + if (source.reloading) { + if (source.armed) { + source.rows = new Map() + source.armed = false + } + completeReload(source) + return `ok` + } + if (source.gate === `error`) return `bad-transition` + if (source.stopped) return `throws` + source.gate = `ready` + return `ok` +} + +// A load of an on-demand Collection that succeeds is one sync transaction of +// the rows it loaded, keyed here by the demand key they serve. As every +// transaction, it is a reload's first commit while the reload's replacement +// is armed, and then replaces every row. +function commitLoad( + counter: { serial: number }, + source: SourceModel, + keys: Iterable<number>, +): void { + const open = openTransaction(source) + for (const key of keys) open.writes.set(key, ++counter.serial) + commitOpen(source) } // A reloaded Collection settles when its new run commits (ready) or fails. @@ -1818,6 +1922,27 @@ const restart: Step = { kind: `restart` } // Named histories, one per distinction the law draws. const namedHistories: Record<string, ReadonlyArray<Step>> = { + "a cascade cancels a query's reload that waits for its sources": [ + on(`fail`, `A`), + { kind: `reload-query` }, + { kind: `cleanup-hold`, source: `B` }, + on(`ready`, `A`), + ], + "a truncate's markReady after fail() on a ready Collection does nothing": [ + { kind: `ready`, source: `A` }, + { kind: `ready`, source: `B` }, + { kind: `reload`, source: `A` }, + { kind: `write`, source: `A`, key: 1, untagged: true }, + { kind: `truncate`, source: `A`, markReady: true }, + { kind: `fail`, source: `A` }, + { kind: `commit`, source: `A` }, + ], + 'a cleanup after a crash that followed cleanup settles at once': [ + { kind: `cleanup-hold`, source: `A` }, + { kind: `cleanup-settle`, source: `A` }, + { kind: `crash`, source: `A` }, + { kind: `cleanup-hold`, source: `A` }, + ], "a reload's default truncate after a failed first load makes it ready": [ on(`fail`, `A`), on(`ready`, `B`), @@ -3701,20 +3826,535 @@ describe(`on-demand first load after query readiness`, () => { }) }) +// --------------------------------------------------------------------------- +// The demand model, shared by the on-demand grammars +// --------------------------------------------------------------------------- + +/** + * # Demand on an on-demand Collection + * + * The window and lazy grammars below read an on-demand Collection, so the + * live query holds logical demands: data it asked that Collection to load. + * Both grammars use one model of the Collection and its demand, and the + * main grammar's laws for the Collection itself: + * + * - **The Collection** is the main grammar's source model, driven through + * `applySourceStep`: its status, node, reload, and rows follow the same + * rules. Its rows arrive through loads. A load that succeeds is one sync + * transaction of its rows (`commitLoad`), so while a reload's replacement + * is armed, the reload's first load replaces every row, as any reload's + * first commit does. A load of a retired sync run commits nothing. A + * demand key is loaded while the Collection has its rows. + * - **The demand** is a set of held keys. The lazy grammar holds one key per + * parent row. The window grammar's query holds one logical demand, its + * window, as key 0 (`windowDemand`); this is an abstraction, since + * production keys that demand by its window, not by a number. + * + * The demand rules come from `packages/db/src/query/live/ARCHITECTURE.md` + * and the glossary's request-error entry: + * + * - **Law 10.** A key's latest load that the query did not abort decides + * it; an older load that settles later decides nothing. Before + * initial-query readiness, a failed load of a held key fails the query's + * first load. After it, the failure is a request error on the query's own + * node (law 16) until a later load of that key succeeds or the query + * releases the key. + * - **Law 9.** A truncate, or a source reload, starts a replay of the held + * demand. New demand joins the open replay, also one whose last attempt + * failed. A demand change is one step: the loads it starts join the replay + * before the loads it retires stop counting. The replay is judged once all + * its loads have settled, against the demand held then. A failure belongs + * to the logical demand that failed: if that demand retired, the replay + * succeeds for the rest. A replay whose demand all retired with nothing + * loaded is aborted, and its error stays. Older overlapping replay work + * holds the replay and decides nothing. Replacing an acquisition does not + * release its owner: a demand parked behind a Collection whose first load + * failed is still held, so the replay waits for it. + * - **Law 16.** A failed replay is a request error until a replay succeeds. + * A replay's success clears the errors of the keys it loaded again. A load + * that threw holds no demand, so a replay does not load its key again. + * + * Which loads the query requests is the loader's choice, so the model does + * not predict requests. The driver reports each request as the adapter saw + * it: its keys, whether it threw, and whether the query aborted it + * (`reportRequests`). The model then judges every outcome. Because it does + * not predict requests, `expectHeldDemandCovered` checks the other side: + * after each step of a ready query, every held key has a load that the + * adapter still serves, its rows, a replay failure awaiting judgment, or a + * shown request error. A load that the query aborts without a replacement + * leaves its key uncovered. + */ + +// One load the query requested, as the adapter saw it. +// `replay`: the load belongs to an open replay. `fromReplay`: the replay +// itself made the request, not a demand change. +// `superseded`: a newer truncate replaced the replay attempt that made it. It +// still holds the replay's completion, decides nothing, and its rows apply +// if it succeeds. +type DemandLoad = { + serial: number + keys: Set<number> + replay: boolean + fromReplay: boolean + superseded: boolean +} + +type QueryGate = `loading` | `ready` | `error` | `crashed` + +type Demand = { + held: Set<number> + // Held keys whose latest load failed after readiness, with that load's + // serial. + failed: Map<number, number> + // A replay is open: every load the query starts joins it. + replayActive: boolean + // A replay failed. The query keeps its last complete publication, and the + // error shows until a replay succeeds or the query holds nothing. + replayFailed: boolean + // The serial of the latest replay load whose failure the replay shows. + replayError: number + pending: Array<DemandLoad> + // Held keys a replay failed for when it was judged, with the serial of the + // failed load. + replayFailedKeys: Map<number, number> + // Replay loads that failed in the current attempt, by serial; the replay + // judges them when it settles. + replayFailedLoads: Map<number, Set<number>> + // A load of the open replay succeeded, in any attempt, and the keys its + // loads loaded, with the serial of each key's latest such load. + replayLoaded: boolean + replayLoadedKeys: Map<number, number> + // Loads that a replay retired and the adapter still serves. As for a + // truncate, a load started before a replay still holds the query's first + // load until it settles, while the query holds one of its keys; it decides + // nothing else. + retiredInFlight: Map<number, Set<number>> + // Every load the query requested and did not abort, by serial. + requested: Map<number, Set<number>> + // A reload retired the sync run while the query held no acquisition, so + // it started no replay; its first commit then replays as a truncate does. + replayAtFirstCommit: boolean + // Loads that threw: they hold no acquisition. + thrown: Set<number> + // A replay started since the last report: the requests now reported are + // its own. + replayIssued: boolean +} + +// `serial` numbers the source's reported problems and written rows. +type OnDemandModel = { + gate: QueryGate + demand: Demand + source: SourceModel + serial: number +} + +// The window grammar's one logical demand. +const windowDemand = 0 + +function freshOnDemandModel(held: Iterable<number>): OnDemandModel { + return { + gate: `loading`, + demand: { + held: new Set(held), + failed: new Map(), + replayActive: false, + replayFailed: false, + replayError: 0, + pending: [], + replayFailedKeys: new Map(), + replayFailedLoads: new Map(), + replayLoaded: false, + replayLoadedKeys: new Map(), + retiredInFlight: new Map(), + requested: new Map(), + replayAtFirstCommit: false, + replayIssued: false, + thrown: new Set(), + }, + source: freshSource(), + serial: 0, + } +} + +function isLoaded(model: OnDemandModel, key: number): boolean { + return model.source.rows.has(key) +} + +// After fail() after readiness the adapter's calls throw, until a reload +// starts a new sync run. The drivers then make no truncate (law-silent: a +// truncate after fail() is outside the grammars). +function adapterStopped(source: SourceModel): boolean { + return source.gate === `crashed` || (!source.reloading && !!source.stopped) +} + +// The serial of the latest load the query requested for a key and did not +// abort. +function latestLoadFor(demand: Demand, key: number): number | undefined { + let latest: number | undefined + for (const [serial, keys] of demand.requested) + if (keys.has(key) && (latest === undefined || serial > latest)) + latest = serial + return latest +} + +// A load the query requested settles. The driver has already committed its +// rows if its sync run is current; its outcome decides each key it is the +// latest load of. +function settleDemandLoad( + model: OnDemandModel, + load: DemandLoad, + succeeded: boolean, +): void { + const { demand } = model + demand.pending = demand.pending.filter((entry) => entry !== load) + if (model.gate === `crashed`) return + if (load.superseded) { + settleReplay(model) + return + } + const latestFor = (key: number) => latestLoadFor(demand, key) === load.serial + // A demand change's replacement that applies for every held key releases + // the older loads it replaces: they no longer hold the first load. + if ( + succeeded && + !load.fromReplay && + [...demand.held].every((key) => load.keys.has(key)) + ) + demand.retiredInFlight.clear() + if (succeeded && !load.fromReplay) + // A demand change's load that covers a superseded load's held keys + // replaces that coverage. A newer replay still waits for the older work. + demand.pending = demand.pending.filter( + (entry) => + !entry.superseded || + ![...entry.keys] + .filter((key) => demand.held.has(key)) + .every((key) => load.keys.has(key)), + ) + if (succeeded) { + for (const key of load.keys) { + // A replay load counts for the replay's own judgment. One that the + // replay itself issued replaces an earlier acquisition, so the replay + // decides its key (law 9): its error clears when the replay succeeds. + if (load.replay) + demand.replayLoadedKeys.set( + key, + Math.max(demand.replayLoadedKeys.get(key) ?? 0, load.serial), + ) + if (load.fromReplay) continue + // A demand's own load: its key's request error clears (the glossary: a + // later subset load for that demand fulfills), unless a newer load of + // it failed and this load no longer holds the key (the query aborted + // it). + if ( + (demand.failed.get(key) ?? 0) < load.serial || + demand.requested.has(load.serial) + ) + demand.failed.delete(key) + } + if (load.replay) demand.replayLoaded = true + settleReplay(model) + return + } + // Only the latest load of a key the query still holds decides it. + const held = [...load.keys].filter( + (key) => demand.held.has(key) && latestFor(key), + ) + // Before initial-query readiness, that failure fails the query's first + // load. + if (model.gate === `loading` && !load.replay) { + if (held.length > 0) model.gate = `error` + return + } + // Law 9 judges a replay when it settles, against the demand held then. + if (load.replay) demand.replayFailedLoads.set(load.serial, load.keys) + else for (const key of held) demand.failed.set(key, load.serial) + settleReplay(model) +} + +// A replay settles once none of its loads is pending. One that failed for a +// key the query still holds then fails the query's first load, or shows a +// request error after it. +function settleReplay(model: OnDemandModel): void { + const { demand } = model + // A superseded load holds the replay only while the query holds its data. + const holds = (entry: DemandLoad) => + entry.replay && + (!entry.superseded || [...entry.keys].some((key) => demand.held.has(key))) + if (demand.pending.some(holds)) return + // A demand parked behind a Collection whose first load failed made no + // request, but it is still held, and the replay waits for it. It ends when + // the Collection restarts and the demand loads, or when the demand + // retires. A key that a replay load settled for is judged instead. + const settledInReplay = (key: number) => + demand.replayLoadedKeys.has(key) || + demand.replayFailedKeys.has(key) || + [...demand.replayFailedLoads.values()].some((keys) => keys.has(key)) + if ( + model.source.gate === `error` && + [...demand.held].some( + (key) => !isLoaded(model, key) && !settledInReplay(key), + ) + ) + return + // Law 10: a key's latest load decides it, so a replay load that loaded a + // key after its failed load replaces that failure. + for (const [key, serial] of demand.replayFailedKeys) + if ((demand.replayLoadedKeys.get(key) ?? 0) > serial) + demand.replayFailedKeys.delete(key) + for (const [serial, keys] of demand.replayFailedLoads) + for (const key of keys) + if (demand.held.has(key) && latestLoadFor(demand, key) === serial) { + demand.replayFailedKeys.set(key, serial) + demand.replayError = Math.max(demand.replayError, serial) + } + demand.replayFailedLoads = new Map() + // A failed key stays failed while the query holds it; once the query + // retires it, its failure cannot poison a successful replacement for the + // remaining demand. + for (const key of demand.replayFailedKeys.keys()) + if (!demand.held.has(key)) demand.replayFailedKeys.delete(key) + // A replay stays open until it succeeds: a failed one is judged again + // when its demand changes. + const open = demand.replayActive || demand.replayFailed + demand.replayActive = false + if (demand.replayFailedKeys.size === 0) { + // The replacement succeeded: its request error clears, and so do the + // errors of the keys it loaded again. A replay whose demand all retired + // with none loaded is aborted instead, and its error stays. + if (open && demand.replayLoaded) { + demand.replayFailed = false + // A key's failure after its replay load stays: the later load decides. + for (const [key, serial] of demand.replayLoadedKeys) + if ((demand.failed.get(key) ?? 0) < serial) demand.failed.delete(key) + demand.replayLoaded = false + demand.replayLoadedKeys = new Map() + return + } + // A replay that loaded nothing, while the query still holds demand whose + // loads threw, has not replaced the rows: it stays open, keeps the last + // complete publication private, and new demand joins it (law 9). + if (open && demand.held.size > 0) demand.replayActive = true + return + } + if (model.gate === `loading`) model.gate = `error` + else demand.replayFailed = true +} + +// A truncate or a source reload starts a replay of the held demand. +// A reload retires the old sync run: its loads no longer apply, and the +// query's own loads still hold its first load until they settle; the old +// replay's loads stop. A truncate keeps the run: its replay loads replace +// the open attempt's, which still hold the replay and decide nothing, and +// the query's own loads decide nothing either. +function startReplay(model: OnDemandModel, cause: `reload` | `truncate`): void { + const { demand } = model + for (const load of demand.pending) { + if (cause === `truncate` && load.replay) load.superseded = true + else if (cause === `truncate` || !load.fromReplay) + demand.retiredInFlight.set(load.serial, load.keys) + } + // A replay that this one replaces reports nothing, and what it loaded does + // not count for this one. + demand.replayFailedKeys = new Map() + demand.replayFailedLoads = new Map() + demand.replayLoaded = false + demand.replayLoadedKeys = new Map() + demand.pending = + cause === `truncate` ? demand.pending.filter((load) => load.superseded) : [] + if (cause === `truncate`) demand.requested = new Map() + demand.replayAtFirstCommit = false + // A reload replays the demand when it retires the run only if the query + // holds an acquisition: a held key with a load that did not throw. + // Otherwise its first commit replays. + // Demand parked behind a failed first load is an acquisition too. + const holdsAcquisition = + (model.source.gate === `error` && demand.held.size > 0) || + [...demand.requested].some( + ([serial, keys]) => + !demand.thrown.has(serial) && + [...keys].some((key) => demand.held.has(key)), + ) + if (cause === `reload` && !holdsAcquisition) { + demand.replayAtFirstCommit = true + return + } + demand.replayActive = true + demand.replayIssued = true +} + +// A source action that may commit: when it is the first commit of a reload +// that started no replay, that commit replays the demand as a truncate does. +function commitOnDemand(model: OnDemandModel, commit: () => void): void { + const armed = model.source.reloading && model.source.armed + commit() + if (armed && !model.source.armed && model.demand.replayAtFirstCommit) + startReplay(model, `truncate`) +} + +function changeDemand( + demand: Demand, + kind: `parent` | `unparent`, + key: number, +): void { + if (kind === `parent`) { + demand.held.add(key) + return + } + demand.held.delete(key) + demand.failed.delete(key) + // Releasing an owner removes both its current and its older work from + // readiness (law 9): older replay work for keys the query no longer holds + // holds nothing, even if the query asks for those keys again. + demand.pending = demand.pending.filter( + (load) => + !load.superseded || [...load.keys].some((held) => demand.held.has(held)), + ) + if (demand.held.size === 0) demand.replayFailed = false +} + +// Requests the adapter saw since the last report. `aborted`: requests the +// query aborted since then. `stillServed`: the adapter still serves the +// request. `replayMade`: a truncate or a reload in this step made the +// requests, so they belong to its replay. +function reportRequests( + model: OnDemandModel, + requests: ReadonlyArray<{ + serial: number + keys: Set<number> + thrown: boolean + }>, + aborted: ReadonlySet<number>, + stillServed: (serial: number) => boolean, + replayMade: boolean, +): void { + const { demand } = model + const fromReplay = replayMade || demand.replayIssued + demand.replayIssued = false + // An aborted request is obsolete: it decides nothing. A superseded replay + // load that the adapter still serves stays: it holds its replay until it + // settles. + const retired = (load: DemandLoad) => + aborted.has(load.serial) && !(load.superseded && stillServed(load.serial)) + // Law 9: a demand change is one step. The loads it starts join an open + // replay before the loads it retires stop counting, so the replay settles + // once, after the whole change. A request a new demand makes during a + // replay joins the replay, but one that throws fails at once; one the + // replay makes fails with the replay. Every request is recorded before a + // thrown one settles, so the replay's later requests still belong to it. + const reported = requests.map((request) => { + const load: DemandLoad = { + serial: request.serial, + keys: request.keys, + replay: + (demand.replayActive || demand.replayFailed) && + (fromReplay || !request.thrown), + fromReplay, + superseded: false, + } + demand.requested.set(load.serial, load.keys) + demand.pending.push(load) + if (load.replay) demand.replayActive = true + return { load, thrown: request.thrown } + }) + for (const { load, thrown } of reported) + if (thrown) { + demand.thrown.add(load.serial) + settleDemandLoad(model, load, false) + } + demand.pending = demand.pending.filter((load) => !retired(load)) + for (const serial of aborted) demand.requested.delete(serial) + settleReplay(model) +} + +// The query's status follows its source until it is ready: a crash crashes +// it at any time, and a failed first load fails the query's first load. +function projectSourceGate(model: OnDemandModel): void { + if (model.source.gate === `crashed`) model.gate = `crashed` + else if (model.gate === `loading` && model.source.gate === `error`) + model.gate = `error` +} + +// During the first load, the query is ready once no pending load is for a +// key it still holds: a demand it retired no longer holds readiness back. A +// replay restores the demand as one unit, so the query also waits until the +// replay settles. +function deriveDemandGate(model: OnDemandModel): void { + const { demand } = model + if ( + model.gate !== `loading` || + demand.replayActive || + [...demand.retiredInFlight.values()].some((keys) => + [...keys].some((key) => demand.held.has(key)), + ) + ) + return + const blocking = demand.pending.some(({ serial, keys }) => + [...keys].some( + (key) => demand.held.has(key) && latestLoadFor(demand, key) === serial, + ), + ) + if (!blocking) model.gate = `ready` +} + +// The request error the query's node shows: the latest failure among its +// held keys' failed loads and a failed replay, or none. +function expectedRequestError(demand: Demand): number | null { + const serials = [ + ...demand.failed.values(), + ...(demand.replayFailed ? [demand.replayError] : []), + ] + return serials.length === 0 ? null : Math.max(...serials) +} + +// The coverage law (laws 9 and 10): once the query is ready, every key it +// holds is covered by a load the adapter still serves, by its rows, by a +// replay failure awaiting judgment, or by the request error the query shows +// for it. `served` comes from the driver's adapter calls, not from +// production state. Law-silent, recorded: a Collection whose first load +// failed parks new demand without a request (law 16 shows that problem on +// the Collection's node), so a key that never had a request error is covered +// while the Collection shows that failure. +function expectHeldDemandCovered( + model: OnDemandModel, + served: Iterable<Set<number>>, + errorShown: boolean, + label: string, +): void { + if (model.gate !== `ready`) return + const { demand } = model + const live = new Set([...served].flatMap((keys) => [...keys])) + const judged = new Set( + [...demand.replayFailedLoads.values()].flatMap((keys) => [...keys]), + ) + const parked = model.source.gate === `error` + const uncovered = [...demand.held].filter( + (key) => + !live.has(key) && + !isLoaded(model, key) && + !judged.has(key) && + !(errorShown && (demand.failed.has(key) || demand.replayFailed)) && + !(parked && !demand.failed.has(key)), + ) + expect(uncovered, `${label}: held demand is covered`).toEqual([]) +} + // --------------------------------------------------------------------------- // On-demand demand grammar // --------------------------------------------------------------------------- /** * A live query over an on-demand Collection loads only the window it shows. - * The law, from the request-error rules above: a load that fails before the - * query's initial-query readiness fails the query's first load; one that - * fails after it is a request error on the query's own node, and the source - * stays healthy. A request error clears only when a load that started after - * the failure succeeds; a source's reload loads the window again and clears - * it when that replay succeeds. The source's own problems after readiness - * are health tree detail and never change the query's status; its crash - * crashes the query. + * The query holds one logical demand, its window, and the shared demand + * model above judges it: a load that fails before the query's initial-query + * readiness fails the query's first load; one that fails after it is a + * request error on the query's own node, and the source stays healthy. A + * request error clears only when a later load of the window succeeds; a + * source's reload replays the window and clears it when that replay + * succeeds. The source is the shared source model: its own problems after + * readiness are health tree detail and never change the query's status, and + * its crash crashes the query. * * The provider has rows 1 to 99. It answers a request without a limit with * every row, and a limited request with the rows up to the end of the @@ -3724,17 +4364,16 @@ describe(`on-demand first load after query readiness`, () => { * every pending load at once (the overlapping-load lane owns the order of * two loads); `reload` reloads the source; `report`, `healthy`, and `fail` * are the source adapter's calls after readiness; `crash` crashes the - * source. + * source. The source marks ready inside sync(), so a reload completes at + * once: its first commit has no rows, whatever the adapter writes later. * - * Which loads a window needs is the loader's choice, so the model does not - * predict requests: the driver reports each load the query requests, as the - * adapter sees it. The model keeps the query's gate, its request error, - * whether a load was requested after that error, the pending loads, the - * window, and the source's node. The comparison checks, after every step, - * the query's status, its own error, the source's node, and, when no load is - * pending and no error shows, the rows in the window. + * The window projection keeps the window offset, the window the query shows, + * and a window move that waits for a replay. The comparison checks, after + * every step, the query's status, its own error message, the source's node, + * the coverage law, and, when no load is in flight and no error shows, the + * rows in the window. */ -type DemandStep = +type WindowStep = | `window` | `settle-ok` | `settle-fail` @@ -3744,114 +4383,73 @@ type DemandStep = | `fail` | `crash` -type DemandModel = { - gate: `loading` | `ready` | `error` | `crashed` - requestError: string | null - // A load started after the shown request error. - laterLoad: boolean - pending: number +// The window projection over the shared demand model. +type WindowModel = OnDemandModel & { offset: number // The window whose load last succeeded; the query shows it. shown: number - // A source reload's replay is pending; a window move made now waits for - // it, and a later reload aborts that move (setWindow() rejects). - replayPending: boolean - // A replay failed: the query keeps its last complete snapshot until a - // later replay succeeds, so a window move waits for that replay too. - replayFailed: boolean + // A window move made while a replay is open, or after one failed, waits + // for the replay; a later reload aborts that move (setWindow() rejects) + // and returns to `replayedOffset`. A move with its own load joins the + // replay instead, so the replay loads it. moveWaitsForReplay: boolean - // The window before the move that waits for a replay: a reload that - // aborts the move returns to it. A move with its own load joins the - // replay, so the replay loads it. replayedOffset: number - source: { failure: string | null; stopped: string | null; crashed: boolean } - serial: number -} - -// The query requested `count` loads at the adapter boundary. A window move -// that requests none is served from rows the query already loaded, unless it -// waits for a replay. -function requestedLoads( - model: DemandModel, - count: number, - step?: DemandStep, -): void { - if (count === 0) { - if (step === `window` && model.pending === 0 && !model.moveWaitsForReplay) - model.shown = model.offset - return - } - model.pending += count - if (model.requestError) model.laterLoad = true } -function applyDemandModel(model: DemandModel, step: DemandStep): void { +function applyWindowStep(model: WindowModel, step: WindowStep): void { if (model.gate === `crashed`) return + const { source, demand } = model switch (step) { case `window`: if (model.gate !== `ready`) return - if (model.replayPending || model.replayFailed) { + if (demand.replayActive || demand.replayFailed) { if (!model.moveWaitsForReplay) model.replayedOffset = model.offset model.moveWaitsForReplay = true } model.offset += 2 return + // The driver settles loads and reports each outcome. case `settle-ok`: - if (model.pending === 0) return - model.pending = 0 - model.replayPending = false - model.replayFailed = false - model.moveWaitsForReplay = false - if (model.gate === `loading`) model.gate = `ready` - if (model.requestError && model.laterLoad) model.requestError = null - model.shown = model.offset - return case `settle-fail`: - if (model.pending === 0) return - model.pending = 0 - if (model.replayPending) model.replayFailed = true - model.replayPending = false - model.moveWaitsForReplay = false - if (model.gate === `loading`) { - model.gate = `error` - return - } - model.requestError = `root load ${++model.serial} failed` - model.laterLoad = false - // A failed window move keeps the last good window; the app retries - // with setWindow(). - model.offset = model.shown return - case `reload`: - // The new run is ready inside sync(), so its first commit at once - // clears the node; the replay loads the window again. - if (model.source.crashed) return - model.source.failure = null - model.source.stopped = null + case `reload`: { + const starts = !source.reloading && source.gate !== `crashed` + applySourceStep(model, source, `source`, { kind: `reload` }) + if (!starts) break + // The new run marks ready inside sync(), before any commit: the reload + // completes at once, and its replay loads the window again. + commitOnDemand(model, () => + applySourceStep(model, source, `source`, { kind: `mark-ready` }), + ) if (model.moveWaitsForReplay) { model.offset = model.replayedOffset model.moveWaitsForReplay = false } - model.replayPending = true - return - case `report`: - if (model.source.stopped) return - model.source.failure = `source problem` - return - case `healthy`: - if (model.source.stopped) return - model.source.failure = null - return - case `fail`: - if (model.source.stopped) return - model.source.failure = null - model.source.stopped = `source gave up` - return - case `crash`: - model.source.crashed = true - model.gate = `crashed` - return + startReplay(model, `reload`) + break + } + default: + applySourceStep(model, source, `source`, { kind: step }) } + projectSourceGate(model) +} + +// The loads of a step settled at the adapter. A settled window move shows +// its window; a failed one keeps the last good window, and the app retries +// with setWindow(). A move that requested no load is served from rows the +// query already loaded, unless it waits for a replay. +function settleWindow( + model: WindowModel, + step: WindowStep, + settled: number, + inFlight: number, +): void { + if ((step === `settle-ok` || step === `settle-fail`) && settled > 0) + model.moveWaitsForReplay = false + if (step === `settle-ok` && settled > 0) model.shown = model.offset + if (step === `settle-fail` && settled > 0) model.offset = model.shown + if (step === `window` && inFlight === 0 && !model.moveWaitsForReplay) + model.shown = model.offset } // A direct subscriber with the initial state on an on-demand Collection. @@ -3905,26 +4503,39 @@ type DemandReach = ReturnType< > async function runDemandHistory( - steps: ReadonlyArray<DemandStep>, + steps: ReadonlyArray<WindowStep>, directSubscriber = false, reach?: DemandReach, ) { - const model: DemandModel = { - gate: `loading`, - requestError: null, - laterLoad: false, - pending: 0, + const model: WindowModel = { + ...freshOnDemandModel([windowDemand]), offset: 0, shown: 0, - replayPending: false, - replayFailed: false, moveWaitsForReplay: false, replayedOffset: 0, - source: { failure: null, stopped: null, crashed: false }, - serial: 0, } - const loads: Array<ReturnType<typeof createDeferred<void>>> = [] - let params!: SyncParams + // The source marks ready inside sync(). + applySourceStep(model, model.source, `source`, { kind: `mark-ready` }) + // Loads in flight at the adapter, and what the adapter saw since the last + // report: each request, and which of them the query aborted. + const loads: Array<{ + serial: number + deferred: ReturnType<typeof createDeferred<void>> + }> = [] + const requests: Array<{ + serial: number + keys: Set<number> + thrown: boolean + }> = [] + const aborted = new Set<number>() + // The sync run each load belongs to; a retired run's load commits nothing. + const loadRuns = new Map<number, number>() + // Each failed load's message, by serial. + const messages = new Map<number, string>() + let serials = 0 + let run = 0 + let failures = 0 + let params!: SyncParams const boundary = { owns: (_options: LoadSubsetOptions) => false } const source = createCollection<Row, number>({ id: `health-demand-${Math.random().toString(36).slice(2)}`, @@ -3934,16 +4545,27 @@ async function runDemandHistory( sync: { sync: (next) => { params = next + const thisRun = ++run // Rows this sync run has served; a reload's run serves them again. const served = new Set<number>() next.markReady() return { loadSubset: (options: LoadSubsetOptions) => { if (boundary.owns(options)) return true - const load = createDeferred<void>() - loads.push(load) + const serial = ++serials + const deferred = createDeferred<void>() + loads.push({ serial, deferred }) + loadRuns.set(serial, thisRun) + requests.push({ + serial, + keys: new Set([windowDemand]), + thrown: false, + }) + // An aborted load still finishes: its rows apply if its run + // serves it. + options.signal?.addEventListener(`abort`, () => aborted.add(serial)) const end = options.limit === undefined ? 99 : model.offset + 2 - return load.promise.then(() => { + return deferred.promise.then(() => { next.begin() for (let id = 1; id <= end; id++) { if (served.has(id)) continue @@ -3957,6 +4579,34 @@ async function runDemandHistory( }, }, }) + const report = (replayMade: boolean) => { + reportRequests( + model, + requests.splice(0), + aborted, + (serial) => loads.some((load) => load.serial === serial), + replayMade, + ) + aborted.clear() + } + // Settle loads at the adapter and report each outcome to the model. + const settle = (succeeded: boolean) => { + const picked = loads.splice(0) + const message = `root load ${++failures} failed` + for (const { serial, deferred } of picked) { + if (succeeded && loadRuns.get(serial) === run) + commitOnDemand(model, () => + commitLoad(model, model.source, [windowDemand]), + ) + const load = model.demand.pending.find((entry) => entry.serial === serial) + if (!succeeded) messages.set(serial, message) + if (load) settleDemandLoad(model, load, succeeded) + model.demand.retiredInFlight.delete(serial) + if (succeeded) deferred.resolve() + else deferred.reject(new Error(message)) + } + return picked.length + } const query = createLiveQueryCollection((q) => q .from({ row: source }) @@ -3970,16 +4620,16 @@ async function runDemandHistory( void query.preload().catch(() => undefined) await flushPromises() if (directSubscriber) watcher = watchDirectly(source, boundary) - requestedLoads(model, loads.length) + report(false) + deriveDemandGate(model) for (const [index, step] of steps.entries()) { const label = `step ${index} ${step}` - applyDemandModel(model, step) + applyWindowStep(model, step) if (model.moveWaitsForReplay) reach?.reach(`window move waits for a replay`) - if (directSubscriber && model.replayPending) + if (directSubscriber && model.demand.replayActive) reach?.reach(`direct subscriber during a replay`) - let requested = 0 - const before = loads.length + let settled = 0 if (step === `window`) { if (query.status === `ready`) void Promise.resolve( @@ -3989,13 +4639,12 @@ async function runDemandHistory( // Ordered loading may follow a load with more requests; settle until // the window asks for nothing more. for (let round = 0; round < 5 && loads.length > 0; round++) { - for (const load of loads.splice(0)) load.resolve() + settled += settle(true) await flushPromises() + report(false) } - requested = 0 } else if (step === `settle-fail`) { - for (const load of loads.splice(0)) - load.reject(new Error(`root load ${model.serial} failed`)) + settled = settle(false) } else if (step === `reload`) { void source.reload().catch(() => undefined) } else if (step === `crash`) { @@ -4006,41 +4655,43 @@ async function runDemandHistory( else if (step === `healthy`) params.reportHealthy() else params.fail(new Error(`source gave up`)) } catch { - // After fail(), the adapter's calls throw; the model ignores them. + // After fail(), the adapter's calls throw; the model predicts that. } } await flushPromises() - requested += Math.max( - 0, - loads.length - - (step === `settle-fail` || step === `settle-ok` ? 0 : before), - ) - requestedLoads(model, requested, step) + report(step === `reload`) + settleWindow(model, step, settled, loads.length) + deriveDemandGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) watcher?.check(label) + const shownError = expectedRequestError(model.demand) if (model.gate === `ready`) { expect( query.health.error?.message ?? null, `${label}: request error`, - ).toBe(model.requestError) + ).toBe(shownError === null ? null : messages.get(shownError)) } + expectHeldDemandCovered( + model, + loads.map(() => new Set([windowDemand])), + query.health.error !== null, + label, + ) const sourceNode = query.health.sources[0]! - if (!model.source.crashed) { + if (model.source.gate !== `crashed`) { expect( sourceNode.failureReason?.message ?? null, `${label}: source failureReason`, - ).toBe(model.source.stopped ? null : model.source.failure) + ).toBe( + model.source.stopped ? null : (model.source.failure?.message ?? null), + ) expect( sourceNode.error?.message ?? null, `${label}: source error`, - ).toBe(model.source.stopped) + ).toBe(model.source.stopped?.message ?? null) } - if ( - model.gate === `ready` && - model.pending === 0 && - model.requestError === null - ) { + if (model.gate === `ready` && loads.length === 0 && shownError === null) { expect( query.toArray.map((row) => row.id), `${label}: window rows`, @@ -4050,7 +4701,7 @@ async function runDemandHistory( } finally { console.error = consoleError watcher?.stop() - for (const load of loads.splice(0)) load.resolve() + for (const { deferred } of loads.splice(0)) deferred.resolve() await query.cleanup() await source.cleanup({ dependents: true }) } @@ -4058,7 +4709,7 @@ async function runDemandHistory( describe(`on-demand demand grammar`, () => { // Named histories, one per distinction the law draws. - const named: Record<string, ReadonlyArray<DemandStep>> = { + const named: Record<string, ReadonlyArray<WindowStep>> = { 'a failed load after readiness is a request error, and a later load clears it': [`settle-ok`, `window`, `settle-fail`, `window`, `settle-ok`], 'a failed first load fails the query': [`settle-fail`], @@ -4110,7 +4761,7 @@ describe(`on-demand demand grammar`, () => { for (const [name, steps] of Object.entries(named)) it(name, () => runDemandHistory(steps)) - const step = fc.constantFrom<DemandStep>( + const step = fc.constantFrom<WindowStep>( `window`, `settle-ok`, `settle-fail`, @@ -4130,7 +4781,7 @@ describe(`on-demand demand grammar`, () => { }) // A prefix that enters a declared reach state that random steps alone // rarely reach at the base run count; random steps follow it. - const demandReachPrefixes: ReadonlyArray<ReadonlyArray<DemandStep>> = [ + const demandReachPrefixes: ReadonlyArray<ReadonlyArray<WindowStep>> = [ [`settle-ok`, `reload`, `window`], [`settle-ok`, `reload`, `settle-fail`, `window`], ] @@ -4141,7 +4792,7 @@ describe(`on-demand demand grammar`, () => { fc.option(fc.constantFrom(...demandReachPrefixes), { freq: 2 }), fc.array(step, { maxLength: 10 }), ) - .map(([prefix, rest]): Array<DemandStep> => [ + .map(([prefix, rest]): Array<WindowStep> => [ ...(prefix ?? []), ...rest, ]), @@ -4167,23 +4818,22 @@ describe(`on-demand demand grammar`, () => { /** * A live query that left-joins an eager parent Collection to an on-demand - * child Collection loads the children of the parents it shows. The law: a - * load that fails before initial-query readiness fails the query's first - * load. After it, a failed load is a request error on the query's node while - * the query still needs a key whose load failed; it clears when a later load - * of that key succeeds, or when the query no longer needs the key. A source - * reload replays the query's demand; a failed replay is a request error - * until a later replay succeeds. Each parent shows its child once a load of - * the child's key succeeded. + * child Collection loads the children of the parents it shows: it holds one + * demand key per parent. The shared demand model above judges every load, + * and the shared source model keeps the child Collection. Each parent shows + * its child while the child Collection has that child's row. * - * Steps: `parent` adds parent 1, 2, or 3, and `unparent` removes it; the - * other steps are as in the window grammar. The driver reports each load's - * requested keys from the adapter boundary, and the model judges outcomes. - * The comparison checks the query's status, whether its node shows an - * error, and, when no load is pending, each parent row and its child. After a - * failed replay the query keeps its last complete snapshot private until a - * replay succeeds; the model does not track that snapshot, so the rows check - * waits for the replay to succeed. + * Steps: `parent` adds parent 1, 2, or 3, and `unparent` removes it; + * `truncate` commits the child adapter's truncate; `child-ready` calls a + * held markReady(); the other steps are as in the window grammar. The driver + * reports each load's requested keys from the adapter boundary, and the + * model judges outcomes. The comparison checks the query's status, whether + * its node shows an error, the coverage law, the keys a truncate replay + * requests, and, when no load is pending and no replay is open or failed, + * each parent row and its child and the rows an Effect over the same join + * received. While a replay is open or failed the query keeps its last + * complete publication private (law 9), which the model does not track, so + * the rows check waits for the replay to succeed. */ // `settle-ok` and `settle-fail` settle every pending load; the `first` and // `last` forms settle only the oldest or the newest one. `throw-next` makes @@ -4207,201 +4857,6 @@ type LazyStep = | `crash` } -// One load the query requested, as the adapter saw it. -// `fromReplay`: the replay itself made the request, not a demand change. -// `superseded`: a newer truncate replaced the replay attempt that made it. It -// still holds the replay's completion, decides nothing, and its rows apply -// if it succeeds. -type LazyLoad = { - serial: number - keys: Set<number> - replay: boolean - fromReplay: boolean - superseded: boolean -} - -type LazyModel = { - gate: `loading` | `ready` | `error` | `crashed` - parents: Set<number> - loaded: Set<number> - // Needed keys whose last load failed after readiness, with that load's - // serial. - failed: Map<number, number> - // A source reload's replay is in flight: every load the query starts - // joins it, inside its publication barrier. - replayActive: boolean - // A failed replay keeps the query's publication private for all its - // demand, so its error stays until a replay succeeds or the query has no - // demand left. - replayFailed: boolean - pending: Array<LazyLoad> - // Keys a replay load failed for; the replay reports the failure once all - // its loads settle, if the query still needs one of them then. - replayFailedKeys: Set<number> - // Replay loads that failed in the current attempt, by serial; the replay - // judges them when it settles. - replayFailedLoads: Map<number, Set<number>> - // A load of the current replay attempt succeeded, and the keys it loaded. - replayLoaded: boolean - replayLoadedKeys: Set<number> - // Aborted loads the current run may still finish: their rows apply. - obsolete: Map<number, Set<number>> - // Loads a reload retired that are still in flight at the adapter. As for a - // truncate, a load started before the replay still holds the query's first - // load until it settles, while the query still needs one of its keys; it - // decides nothing else. - retiredInFlight: Map<number, Set<number>> - // Every load the query requested and did not abort, by serial: a key's - // latest such load decides it, so an older load that settles later changes - // nothing, and an aborted one leaves the decision to the one before it. - requested: Map<number, Set<number>> - // `ready`: the child Collection's status is `ready`. `firstLoadFailed`: - // its sync run failed its first load (status `error`). - source: { - failure: string | null - stopped: string | null - crashed: boolean - ready: boolean - firstLoadFailed: boolean - // A reload's new run has not marked ready yet, and the child was not - // ready when it started. - reloadRun: boolean - // A reload is in flight: another reload() returns its promise. Only a - // held markReady() keeps it in flight past the step. - reloadInFlight: boolean - // The `holdChildReady` option. - holdsReady: boolean - // The reload's first commit, which replaces every child row, has not - // happened yet: the rows from before the reload stay until it does. - replacementPending: boolean - } -} - -// A load the query requested settles. Its rows apply on success either way; -// its outcome decides each key it was the latest load of. -function settleLazyLoad( - model: LazyModel, - load: LazyLoad, - succeeded: boolean, -): void { - model.pending = model.pending.filter((entry) => entry !== load) - if (model.gate === `crashed`) return - if (load.superseded) { - if (succeeded) for (const key of load.keys) model.loaded.add(key) - settleReplay(model) - return - } - const latestFor = (key: number) => latestLoadFor(model, key) === load.serial - // A demand change's replacement that applies for every needed key releases - // the older loads it replaces: they no longer hold the first load. - if ( - succeeded && - !load.fromReplay && - [...model.parents].every((key) => load.keys.has(key)) - ) - model.retiredInFlight.clear() - if (succeeded && !load.fromReplay) - // A demand change's load that covers a superseded load's keys replaces - // that coverage. A newer replay still waits for the older work. - model.pending = model.pending.filter( - (entry) => - !entry.superseded || - ![...entry.keys] - .filter((key) => model.parents.has(key)) - .every((key) => load.keys.has(key)), - ) - if (succeeded) { - for (const key of load.keys) { - model.loaded.add(key) - // A replay load's success clears its key's error only when the whole - // replay succeeds (law 16: its success clears only the errors of - // demands it loaded again). - if (load.replay) { - model.replayLoadedKeys.add(key) - continue - } - // A key loaded: its failure clears, unless a newer load of it failed - // and this load no longer holds the key (the query aborted it). - if ( - (model.failed.get(key) ?? 0) < load.serial || - model.requested.has(load.serial) - ) - model.failed.delete(key) - } - if (load.replay) model.replayLoaded = true - settleReplay(model) - return - } - // Only the latest load of a key the query still needs decides it. - const needed = [...load.keys].filter( - (key) => model.parents.has(key) && latestFor(key), - ) - // Before initial-query readiness, that failure fails the query's first - // load. - if (model.gate === `loading` && !load.replay) { - if (needed.length > 0) model.gate = `error` - return - } - // Law 9 judges a replay when it settles, against the demand held then: a - // failed replay load counts for each key it is then the latest load of. - if (load.replay) model.replayFailedLoads.set(load.serial, load.keys) - else for (const key of needed) model.failed.set(key, load.serial) - settleReplay(model) -} - -// The serial of the latest load the query requested for a key and did not -// abort. -function latestLoadFor(model: LazyModel, key: number): number | undefined { - let latest: number | undefined - for (const [serial, keys] of model.requested) - if (keys.has(key) && (latest === undefined || serial > latest)) - latest = serial - return latest -} - -// A replay settles once none of its loads is pending; one that failed for a -// key the query still needs then fails the query's first load, or shows a -// request error after it. -function settleReplay(model: LazyModel): void { - // A superseded load holds the replay only while the query needs its data. - const holds = (entry: LazyLoad) => - entry.replay && - (!entry.superseded || [...entry.keys].some((key) => model.parents.has(key))) - if (model.pending.some(holds)) return - const settling = model.replayActive - model.replayActive = false - for (const [serial, keys] of model.replayFailedLoads) - for (const key of keys) - if (model.parents.has(key) && latestLoadFor(model, key) === serial) - model.replayFailedKeys.add(key) - model.replayFailedLoads = new Map() - // ARCHITECTURE.md law 9: replay failure is scoped to the logical demand - // that failed. A failed key stays failed while the query holds it; once - // the query retires it, its failure cannot poison a successful - // replacement for the remaining demand. - model.replayFailedKeys = new Set( - [...model.replayFailedKeys].filter((key) => model.parents.has(key)), - ) - const failed = model.replayFailedKeys.size > 0 - const loadedSome = model.replayLoaded - const loadedKeys = model.replayLoadedKeys - model.replayLoaded = false - model.replayLoadedKeys = new Set() - if (!failed) { - // The replacement succeeded: its request error clears, and so do the - // errors of the keys it loaded again. A replay whose participants were - // all released, with none loaded, is aborted instead (law 9: the last - // logical demand retired), and its error stays. - if (settling && loadedSome) { - model.replayFailed = false - for (const key of loadedKeys) model.failed.delete(key) - } - return - } - if (model.gate === `loading`) model.gate = `error` - else model.replayFailed = true -} - // Keys a subset request asks for, read from its predicate's literal values. function requestedKeys(options: LoadSubsetOptions): Set<number> { const keys = new Set<number>() @@ -4422,37 +4877,22 @@ function requestedKeys(options: LoadSubsetOptions): Set<number> { return keys } -// During the first load, the query is ready once no pending load is for a -// key it still needs: a demand it retired no longer holds readiness back. A -// source reload's replay restores the demand as one unit, so the query also -// waits until the replay settles. -function deriveLazyGate(model: LazyModel): void { - if ( - model.gate !== `loading` || - model.replayActive || - [...model.retiredInFlight.values()].some((keys) => - [...keys].some((key) => model.parents.has(key)), - ) - ) - return - const blocking = model.pending.some(({ serial, keys }) => - [...keys].some( - (key) => model.parents.has(key) && latestLoadFor(model, key) === serial, - ), - ) - if (!blocking) model.gate = `ready` -} - -function applyLazyModel(model: LazyModel, step: LazyStep): void { +// The lazy projection over the shared demand model: `parent` and +// `unparent` change the held demand, and the Collection steps go through +// the source layer. `holdChildReady`: every child sync run holds markReady() +// until a `child-ready` step; otherwise it marks ready inside sync() or at +// once after it, within the step. +function applyLazyStep( + model: OnDemandModel, + step: LazyStep, + holdChildReady: boolean, +): void { if (model.gate === `crashed`) return + const { source } = model switch (step.kind) { case `parent`: - model.parents.add(step.key) - return case `unparent`: - model.parents.delete(step.key) - model.failed.delete(step.key) - if (model.parents.size === 0) model.replayFailed = false + changeDemand(model.demand, step.kind, step.key) return // Settle steps reach the model one load at a time, from the driver. case `settle-ok`: @@ -4463,103 +4903,46 @@ function applyLazyModel(model: LazyModel, step: LazyStep): void { case `settle-last-fail`: case `throw-next`: return - case `reload`: - if (model.source.crashed) return - if (model.source.reloadInFlight) return - // The replay loads the demand again; a key it loads is loaded again. - model.source.failure = null - model.source.stopped = null - if (!model.source.ready) model.source.reloadRun = true - model.source.reloadInFlight = model.source.holdsReady - // The retired run's loads no longer apply, and its truncate drops the - // loaded children until the replay loads them again. - // Only the query's own loads hold it; a replay's loads stop when a - // newer replay replaces them. - for (const { serial, keys, fromReplay } of model.pending) - if (!fromReplay) model.retiredInFlight.set(serial, keys) - // A replay this reload discards reports nothing. - model.replayFailedKeys = new Set() - model.replayFailedLoads = new Map() - model.pending = [] - model.obsolete = new Map() - // The new run's first commit replaces every child row. Without a held - // markReady() it commits within this step. - if (model.source.holdsReady) model.source.replacementPending = true - else model.loaded = new Set() - model.replayActive = true - return - case `truncate`: + case `reload`: { + // A reload in flight returns its promise; a crashed child rejects. + const starts = !source.reloading && source.gate !== `crashed` + applySourceStep(model, source, `source`, { kind: `reload` }) + if (!starts) break + startReplay(model, `reload`) + if (!holdChildReady) + commitOnDemand(model, () => + applySourceStep(model, source, `source`, { kind: `mark-ready` }), + ) + break + } + case `truncate`: { + if (adapterStopped(source)) return // The adapter replaces every row in the same sync run, as a - // must-refetch does. The truncate replay loads the demand again. A load - // started before it decides nothing: it may still hold the first load, - // and its rows apply if it succeeds. A failed replay's error stays - // until a later replay succeeds. - if (model.source.crashed || model.source.stopped) return - // Before a reload's first commit, the adapter's truncate commits in - // the reload's first transaction: it is the replacement the reload - // already replays, and no replay row has applied yet. - if (model.source.replacementPending) { - model.source.replacementPending = false - model.loaded = new Set() - return - } - for (const load of model.pending) { - if (load.replay) load.superseded = true - else { - model.retiredInFlight.set(load.serial, load.keys) - model.obsolete.set(load.serial, load.keys) - } - } - model.replayFailedKeys = new Set() - model.replayFailedLoads = new Map() - model.pending = model.pending.filter((load) => load.superseded) - model.requested = new Map() - model.loaded = new Set() - model.replayActive = true - return - case `report`: - // reportError() before Collection readiness throws. - if (!model.source.stopped && model.source.ready) - model.source.failure = `source problem` - return - case `fail`: - if (model.source.stopped || model.source.firstLoadFailed) return - // A reload that fails before its first commit keeps the rows. - model.source.reloadInFlight = false - model.source.replacementPending = false - if (!model.source.ready) { - // The child's first load fails: so does the query's first load. - model.source.firstLoadFailed = true - if (model.gate === `loading`) model.gate = `error` - return - } - model.source.failure = null - model.source.stopped = `source gave up` - model.source.reloadInFlight = false - return + // must-refetch does, and its truncate replay loads the demand again. + // Before a reload's first commit, the truncate commits in the reload's + // first transaction: it is the replacement that the reload already + // replays, and no replay row has applied yet. + const reloadCommit = source.armed + commitOnDemand(model, () => { + applySourceStep(model, source, `source`, { + kind: `truncate`, + markReady: false, + }) + applySourceStep(model, source, `source`, { kind: `commit` }) + }) + if (!reloadCommit) startReplay(model, `truncate`) + break + } + // The held markReady() of the child's current sync run. case `child-ready`: - // The held markReady() of the child's current sync run. After a - // failed first load it throws, unless a reload started a new run. A - // reload's markReady() before any commit replaces the rows with none. - if (model.source.replacementPending) { - model.source.replacementPending = false - model.loaded = new Set() - } - model.source.reloadInFlight = false - if (model.source.ready) return - if (model.source.firstLoadFailed && !model.source.reloadRun) return - model.source.ready = true - model.source.firstLoadFailed = false - model.source.reloadRun = false - // A query in `error` stays there until its own reload(). - return - case `crash`: - model.source.crashed = true - model.source.reloadInFlight = false - model.source.replacementPending = false - model.gate = `crashed` - return + commitOnDemand(model, () => + applySourceStep(model, source, `source`, { kind: `mark-ready` }), + ) + break + default: + applySourceStep(model, source, `source`, { kind: step.kind }) } + projectSourceGate(model) } // The states the lazy grammar's later laws depend on (ORC-015). Only the @@ -4572,6 +4955,8 @@ const lazyReachStates = [ `direct subscriber during a replay`, `effect compared at a quiet checkpoint`, `request error shown`, + `coverage checked during an open replay`, + `parked demand during a replay`, ] as const type LazyReach = ReturnType< typeof createOracleReach<(typeof lazyReachStates)[number]> @@ -4602,33 +4987,10 @@ async function runLazyHistory( reach?: LazyReach } = {}, ) { - const model: LazyModel = { - gate: `loading`, - parents: new Set([1]), - loaded: new Set(), - failed: new Map(), - replayActive: false, - replayFailed: false, - pending: [], - replayFailedKeys: new Set(), - replayLoaded: false, - replayLoadedKeys: new Set(), - replayFailedLoads: new Map(), - obsolete: new Map(), - retiredInFlight: new Map(), - requested: new Map(), - source: { - failure: null, - stopped: null, - crashed: false, - ready: !holdChildReady, - firstLoadFailed: false, - reloadRun: false, - reloadInFlight: false, - holdsReady: holdChildReady, - replacementPending: false, - }, - } + const model = freshOnDemandModel([1]) + // Without a held markReady(), the child marks ready inside sync(). + if (!holdChildReady) + applySourceStep(model, model.source, `source`, { kind: `mark-ready` }) // Loads in flight at the adapter, oldest first, and what the adapter saw // since the last report: each request, and which of them were aborted or // threw. @@ -4642,6 +5004,13 @@ async function runLazyHistory( thrown: boolean }> = [] const aborted = new Set<number>() + // Adapter loads not yet settled or aborted: what the adapter still serves. + const live = new Map<number, Set<number>>() + // The keys and the child sync run of every request; a retired run's load + // commits nothing. + const loadKeys = new Map<number, Set<number>>() + const loadRuns = new Map<number, number>() + let run = 0 let serials = 0 let throwNext = false let failures = 0 @@ -4676,6 +5045,7 @@ async function runLazyHistory( sync: { sync: (params) => { childParams = params + const thisRun = ++run // Children this sync run has served; a reload's run, or a load // after the run's own truncate, serves them again. const served = new Set<number>() @@ -4700,15 +5070,19 @@ async function runLazyHistory( const thrown = throwNext throwNext = false requests.push({ serial, keys, thrown }) + loadKeys.set(serial, keys) + loadRuns.set(serial, thisRun) if (thrown) throw new Error(`child load ${++failures} threw`) const deferred = createDeferred<void>() loads.push({ serial, deferred }) + live.set(serial, keys) // The adapter sees every abort. One that honors aborts also // rejects the request; one that does not may still finish it. options.signal?.addEventListener(`abort`, () => { aborted.add(serial) + live.delete(serial) if (!honorsAbort) return - model.retiredInFlight.delete(serial) + model.demand.retiredInFlight.delete(serial) const index = loads.findIndex((load) => load.serial === serial) if (index !== -1) loads.splice(index, 1) deferred.reject(new DOMException(`aborted`, `AbortError`)) @@ -4739,57 +5113,23 @@ async function runLazyHistory( ) const consoleError = console.error console.error = () => {} - // Requests made since the last report, tagged with whether a source - // reload's replay made them. + // Requests made since the last report, tagged with whether a truncate or + // a source reload in this step made them. const report = (replayMade = false) => { - // An aborted request is obsolete: it decides nothing, but if its run - // still serves it, its rows apply when it succeeds. - // A superseded replay load the adapter still serves stays: it holds its - // replay until it settles. - const retired = (load: LazyLoad) => - aborted.has(load.serial) && - !(load.superseded && loads.some((entry) => entry.serial === load.serial)) - const retireAborted = () => { - for (const load of model.pending) - if (retired(load) && !load.superseded) - model.obsolete.set(load.serial, load.keys) - model.pending = model.pending.filter((load) => !retired(load)) - for (const serial of aborted) model.requested.delete(serial) - } - // Law 9: a demand change is one step. The loads it starts join an open - // replay before the loads it retires stop counting, so the replay settles - // once, after the whole change. - // A request a new demand makes during a replay joins the replay, but one - // that throws fails at once; one the replay makes fails with the replay. - // Every request is recorded before a thrown one settles, so the replay's - // later requests still belong to it. - const reported = requests.splice(0).map((request) => { - const load: LazyLoad = { - serial: request.serial, - keys: request.keys, - // Law 9: new demand joins the private replacement of an open replay, - // also one whose current attempt failed. - replay: - (model.replayActive || model.replayFailed) && - (replayMade || !request.thrown), - fromReplay: replayMade, - superseded: false, - } - model.requested.set(load.serial, load.keys) - model.pending.push(load) - if (load.replay) model.replayActive = true - return { load, thrown: request.thrown } - }) - for (const { load, thrown } of reported) - if (thrown) { - thrownLoads.add(load.serial) - settleLazyLoad(model, load, false) - } - retireAborted() + const reported = requests.splice(0) + for (const request of reported) + if (request.thrown) thrownLoads.add(request.serial) + reportRequests( + model, + reported, + aborted, + (serial) => loads.some((load) => load.serial === serial), + replayMade, + ) aborted.clear() - settleReplay(model) } - // Settle loads at the adapter and report each outcome to the model. + // Settle loads at the adapter and report each outcome to the model. A + // load of the current sync run that succeeds commits its rows first. const settle = ( picked: Array<(typeof loads)[number]>, succeeded: boolean, @@ -4799,20 +5139,14 @@ async function runLazyHistory( loads.findIndex((load) => load.serial === serial), 1, ) - const load = model.pending.find((entry) => entry.serial === serial) - // A reload of a ready child completes at the new run's first commit, - // which replaces every child row. - if (succeeded && load && model.source.ready) - model.source.reloadInFlight = false - if (succeeded && load && model.source.replacementPending) { - model.source.replacementPending = false - model.loaded = new Set() - } - if (load) settleLazyLoad(model, load, succeeded) - else if (succeeded && model.obsolete.has(serial)) - for (const key of model.obsolete.get(serial)!) model.loaded.add(key) - model.obsolete.delete(serial) - model.retiredInFlight.delete(serial) + live.delete(serial) + if (succeeded && loadRuns.get(serial) === run) + commitOnDemand(model, () => + commitLoad(model, model.source, loadKeys.get(serial)!), + ) + const load = model.demand.pending.find((entry) => entry.serial === serial) + if (load) settleDemandLoad(model, load, succeeded) + model.demand.retiredInFlight.delete(serial) if (succeeded) deferred.resolve() else deferred.reject(new Error(`child load ${++failures} failed`)) } @@ -4864,14 +5198,17 @@ async function runLazyHistory( const demandOnly = step.kind === `parent` || step.kind === `unparent` // Open (see the request error check below): a demand change during a // replay can show a request error and clear it at once. - const replayBefore = model.replayActive + const replayBefore = model.demand.replayActive if (step.kind === `reload` && model.gate === `loading`) reach?.reach(`reload during the first load`) - if (step.kind === `truncate` && model.source.reloadInFlight) + if (step.kind === `truncate` && model.source.reloading) reach?.reach(`truncate while a reload is in flight`) - const truncateIsReloadCommit = - step.kind === `truncate` && model.source.replacementPending - applyLazyModel(model, step) + const truncates = + step.kind === `truncate` && + model.gate !== `crashed` && + !adapterStopped(model.source) + const truncateIsReloadCommit = truncates && model.source.armed + applyLazyStep(model, step, holdChildReady) if (step.kind === `parent` || step.kind === `unparent`) { parentParams.begin() parentParams.write({ @@ -4906,7 +5243,7 @@ async function runLazyHistory( } else if (step.kind === `reload`) { void children.reload().catch(() => undefined) } else if (step.kind === `truncate`) { - if (children.status !== `crashed` && !model.source.stopped) { + if (truncates) { childParams.begin() // Readiness stays with markReady(), so a truncate can land while // a reload is still in flight. @@ -4933,33 +5270,30 @@ async function runLazyHistory( // A truncate replays the query's demand: the adapter receives a // request for every key the query still needs. if ( - step.kind === `truncate` && + truncates && !truncateIsReloadCommit && - (model.gate === `ready` || model.gate === `loading`) && - !model.source.stopped + (model.gate === `ready` || model.gate === `loading`) ) expect( - [...model.parents].filter( + [...model.demand.held].filter( (key) => - !thrownLoads.has(model.failed.get(key) ?? 0) && + !thrownLoads.has(model.demand.failed.get(key) ?? 0) && stepRequests.every((request) => !request.keys.has(key)), ), `${label}: keys the truncate replay did not request`, ).toEqual([]) - deriveLazyGate(model) + deriveDemandGate(model) expect(query.status, `${label}: query status`).toBe(model.gate) watcher?.check(label) - if (watcher && model.replayActive) + const { demand } = model + if (watcher && demand.replayActive) reach?.reach(`direct subscriber during a replay`) - if (model.replayFailedKeys.size > 0 || model.replayFailed) + if (demand.replayFailedKeys.size > 0 || demand.replayFailed) reach?.reach(`replay load fails`) - if (model.pending.some((load) => load.superseded)) + if (demand.pending.some((load) => load.superseded)) reach?.reach(`superseded replay load`) - if ( - model.gate === `ready` && - (model.failed.size > 0 || model.replayFailed) - ) + if (model.gate === `ready` && expectedRequestError(demand) !== null) reach?.reach(`request error shown`) expect(query.health, `${label}: stable read`).toBe(query.health) if ( @@ -4975,24 +5309,32 @@ async function runLazyHistory( `${label}: unchanged tree announces nothing`, ).toBe(announcedBefore) } - // Open: a demand change while a source reload's replay is in flight - // can clear a request error before its key loads again (see the - // coverage map). Until that is settled, the check waits for the - // replay to succeed; it still checks the error a failed replay shows. - const replayOpen = - (model.replayActive || model.replayFailed) && - step.kind !== `settle-fail` - if (model.gate === `ready` && !replayOpen) { + if (model.gate === `ready` && demand.replayActive) + reach?.reach(`coverage checked during an open replay`) + if (model.source.gate === `error` && demand.replayActive) + reach?.reach(`parked demand during a replay`) + expectHeldDemandCovered( + model, + live.values(), + query.health.error !== null, + label, + ) + // Law 16: the query's node shows a request error exactly while the + // model holds one, also while a replay is open or after one failed. + if (model.gate === `ready`) { expect(query.health.error !== null, `${label}: request error`).toBe( - model.failed.size > 0 || model.replayFailed, + expectedRequestError(demand) !== null, ) } // After a failed replay the query keeps its last complete snapshot // private, which the model does not track. + // Law 9: while a replay is open, its replacement stays private, so the + // query keeps the rows of its last complete publication. if ( model.gate === `ready` && - model.pending.length === 0 && - !model.replayFailed + demand.pending.length === 0 && + !demand.replayFailed && + !demand.replayActive ) { const rows = query.toArray.map( (row: { parent: Row; child?: Row }) => @@ -5002,15 +5344,15 @@ async function runLazyHistory( [...rows].sort((a, b) => a[0] - b[0]), `${label}: rows`, ).toEqual( - [...model.parents] + [...demand.held] .sort((a, b) => a - b) - .map((key) => [key, model.loaded.has(key) ? key * 10 : null]), + .map((key) => [key, isLoaded(model, key) ? key * 10 : null]), ) // An Effect over the same query that a load failure has not ended // has received the same rows by now. - if (effect && !effect.disposed && !model.replayActive) + if (effect && !effect.disposed) reach?.reach(`effect compared at a quiet checkpoint`) - if (effect && !effect.disposed && !model.replayActive) + if (effect && !effect.disposed) expect( [...effectRows.values()].sort((a, b) => a[0] - b[0]), `${label}: effect rows`, @@ -5202,6 +5544,52 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 3 }, { kind: `settle-last-ok` }, ])) + it(`a demand change aborts a replay load and must request its still-held key again`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 1 }, + ], + { honorsAbort: false, holdChildReady: true }, + )) + // Neighbours of the parked-join history: the parked demand is requested + // when its source restarts, and the replay then settles as usual; the + // parked demand retires instead, which aborts the replay. + it(`a parked demand loads when its source restarts, and the replay then settles`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 1 }, + { kind: `reload` }, + { kind: `settle-ok` }, + ], + { honorsAbort: false, holdChildReady: true }, + )) + it(`a parked replay ends when its last demand retires`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 1 }, + { kind: `unparent`, key: 1 }, + { kind: `unparent`, key: 2 }, + ], + { honorsAbort: false, holdChildReady: true }, + )) it(`a load that fails during a replay keeps its own error`, () => runLazyHistory( [ @@ -5311,6 +5699,210 @@ describe(`lazy join demand grammar`, () => { runLazyHistory([{ kind: `reload` }, { kind: `settle-ok` }], { asyncChildReady: true, })) + // A failed replay stays open for the demand the query still holds: a + // replacement that throws holds no demand, but its key's logical owner + // stays, so the replay does not succeed with nothing loaded (law 9). + it(`a failed replay keeps its error when a replacement for its failed key throws`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `unparent`, key: 1 }, + { kind: `throw-next` }, + { kind: `settle-last-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `parent`, key: 1 }, + { kind: `unparent`, key: 3 }, + ])) + // Law 10: a later load decides its key, also after a replay loaded it. + it(`a load that throws after a replay load keeps its error when the replay succeeds`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `throw-next` }, + { kind: `unparent`, key: 1 }, + ])) + // Law 9: once the failed demand retires, the replay succeeds for the + // demand it loaded, without another load. + it(`a failed replay succeeds when its failed demand retires after the rest loaded`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `unparent`, key: 1 }, + ])) + // Law 10: a replacement that loads the failed key again decides it. + it(`a replacement that loads a failed replay key again lets the replay succeed`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 3 }, + { kind: `settle-ok` }, + ])) + // Law 9: only a demand that made no request waits as parked. A replay + // load that a child with a failed first load still serves fails the replay + // as any replay load does. + it(`a replay load fails the replay while its child shows a failed first load`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `settle-fail` }, + ], + { holdChildReady: true }, + )) + // Law 9: a load that throws fails at once, for its own demand, and the + // replay it would have joined loaded nothing for the demand the query + // still holds, so the replay stays open and keeps its publication private. + it(`a replay stays open when the only replacement for its held demand throws`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `reload` }, + { kind: `throw-next` }, + { kind: `unparent`, key: 1 }, + ])) + // Law 9: a replay load that the query aborted settled nothing, so the + // parked demand that replaces it is still owed a load. + it(`a parked demand that replaces an aborted replay load holds the replay`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `reload` }, + { kind: `fail` }, + { kind: `unparent`, key: 1 }, + ], + { holdChildReady: true }, + )) + // Law 9: a truncate aborts the loads before it and replays the demand; an + // aborted load that the adapter still finishes decides nothing, so a + // request error waits for the replay. + it(`a load the truncate aborted does not clear a request error when it finishes`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-first-ok` }, + ], + { honorsAbort: false }, + )) + // Law 10: a demand change retries a segment whose load failed, also while + // that failure waits for a replay; the retry then decides the key, and the + // first load succeeds. + it(`a demand change retries a load that failed during a replay in the first load`, () => + runLazyHistory( + [ + { kind: `reload` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 3 }, + { kind: `settle-ok` }, + ], + { holdChildReady: true }, + )) + // Law 10 and law 9: a demand change retries a key whose replay load + // failed, and the retry decides it, before the replay is judged. + it(`a demand change retries a key whose truncate replay load failed in the first load`, () => + runLazyHistory( + [ + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-last-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 1 }, + { kind: `settle-ok` }, + ], + { honorsAbort: false }, + )) + // Law 9: releasing an owner removes its older replay work too, so a later + // replay for the same key does not wait for it. + it(`older replay work of a released key does not hold a later replay`, () => + runLazyHistory( + [ + { kind: `truncate` }, + { kind: `truncate` }, + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 1 }, + { kind: `truncate` }, + { kind: `settle-last-fail` }, + ], + { honorsAbort: false }, + )) + // Law 9: a truncate replay whose held demand only threw loaded nothing, so + // it stays open and keeps the last complete publication private. + it(`a truncate replay stays open while its held demand's loads threw`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `truncate` }, + { kind: `settle-first-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `throw-next` }, + { kind: `parent`, key: 1 }, + ])) + // Law 9: replacing an acquisition does not release its logical owner, so + // the older work of a key whose replay load failed still holds the replay. + it(`older replay work of a held key holds the replay after its replay load fails`, () => + runLazyHistory( + [ + { kind: `truncate` }, + { kind: `truncate` }, + { kind: `settle-last-fail` }, + { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + ], + { honorsAbort: false }, + )) + // Law 10: a replay's success does not clear the error of a key whose + // latest load threw after the replay loaded it. + it(`a replay's success keeps the error of a key whose later load threw`, () => + runLazyHistory([ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `throw-next` }, + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 1 }, + ])) + // A reload replays at retire only for a held acquisition; a key whose only + // load threw holds none, so the reload's first commit replays instead and + // decides the load that commit came from. + it(`a reload without a held acquisition replays at its first commit`, () => + runLazyHistory( + [ + { kind: `throw-next` }, + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 2 }, + { kind: `reload` }, + { kind: `parent`, key: 1 }, + { kind: `settle-last-ok` }, + ], + { holdChildReady: true }, + )) const lazyReach = createOracleReach( `source-health.lazy-demand`, lazyReachStates, @@ -5339,6 +5931,10 @@ describe(`lazy join demand grammar`, () => { steps: [{ kind: `truncate` }, { kind: `truncate` }], honorsAbort: false, }, + { + steps: [{ kind: `settle-ok` }, { kind: `fail` }, { kind: `truncate` }], + holdChildReady: true, + }, { steps: [ { kind: `settle-ok` }, From e0937332b28d97653faa2dfcee6aa4695e891caf Mon Sep 17 00:00:00 2001 From: Test <test@test.com> Date: Fri, 23 Oct 2026 15:58:03 -0600 Subject: [PATCH 106/106] test(db): model parked demand in the source-health demand layer By maintainer decision, the lazy model now predicts the acquisition that a demand change parks while the child shows a failed first load, from law 10's growth and churn policy. A declared reach state and prefix enter that region, and the parked-demand exclusions in the coverage check and replay judgment are gone. Production fix, RED on the parent commit: a replay that re-requests a parked demand drops the parked wait, which settled only with that replay, so the replay completes and publishes. Model fixes from the new histories: a replay's failed request keeps its acquisition's coverage; a later change drops a parked replacement that no longer matches; a replay succeeds only after it loaded some held demand (law 9); a reload replays at retire only for a held acquisition. Law: ARCHITECTURE.md laws 9 and 10; maintainer decision on parked demand. Reach: "parked demand during a replay" and "coverage checked during an open replay" reached at the base run count. RED/GREEN: "a truncate replay re-requests parked demand and then publishes it" fails on the parent commit and passes now. Mutant: eight hostile mutants each fail the oracle; 12 runs at 30x pass. Limits: the window grammar settles all loads at once; per-row child Collections are not in the tree. Co-authored-by: Isaac <no-reply@databricks.com> --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/subscription.ts | 21 +- .../db/tests/source-health-oracle.test.ts | 264 ++++++++++++++---- 3 files changed, 227 insertions(+), 60 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 7a4aa09a8c..9064270924 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -58,7 +58,7 @@ that test identifiers must copy production's private data structures. | D2 operator work | `packages/db-ivm/tests/d2-work-oracle.test.ts` | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The oracle compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | | Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay; a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted decides nothing when it finishes; a held replacement that loaded decides the demands it replaces; a replay's success keeps the error of a key whose later load threw; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady` option does nothing on a ready Collection, also after `fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()` is outside the grammars; a window move with no request is served from loaded rows. Open: a child whose first load failed parks new demand without a request, and the lazy model cannot see a parked replacement, so a demand change there followed by a failed load of the replaced segment can show no request error where the model expects one (rare at 30x; needs a maintainer decision on whether parked demand decides its keys). A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials, crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, unless the Collection cleaned up since and started fresh), `cancel` (a commit with an aborted signal changes nothing, and a reload's truncate moves to the run's next commit), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), and restart. After every step the check compares status, rows, the projected tree, node identity, `status:change`/`health:change` order, and `findProblems()`. Named histories pin a reload's new-run `failureReason` surviving its first commit. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled first commit still lets the next commit replace the rows; a diffing adapter sees an empty baseline while a reload's truncate is armed), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios `sync-errors-after-readiness` and `crashed-status` judge the hooks. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()` or a row commit inside `sync()`, or `markReady()` after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows only the new run's own commit. After the loads settle, it checks the new run's rows. Checking for the loader when the truncate applies, before `sync()` returns it, fails the inside-`sync()` cells; a replay that publishes the truncate before its loads settle fails the in-flight cut. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's `reload()`. A query that follows its source into `error` after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls `fail()` before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction: `write` and `commit` steps span other steps, the driver writes like a diffing adapter, a `write` can omit the `tag` field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left). `cleanup-hold` and `cleanup-settle` steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property, `source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()` until a `child-ready` step, so the child can be `loading` or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second `reload()` returns the first one's promise; and the child adapter's own `truncate()` (a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key except one whose load threw, older replay work still holds the replay and decides nothing, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, adapters that ignore aborts (an aborted load that still succeeds in its run applies its rows), and source reloads during the first load: the first load waits for the whole replay, a replay fails only for a key the query still needs when it settles, and a load that threw is not replayed, so its request error stays after the replay succeeds. In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted follows the replay as after a truncate; a named history checks that the retired run's load failing after the replay decides nothing. A named history pins that a truncate after a reload's failed replay replays the demand again. A reload's own first-commit truncate does not start a second replay; a replay that failed before it keeps its error until a later replay succeeds or the app reloads. Open: a demand added while a reload is in flight, before its first commit, is aborted and requested again by that commit's truncate replay (a wasted request; the same before the reload rewrite). Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay keeps its own error (two requests that fail with one error show one problem), that a reload during the first load fails it only for a demand still held when the replay settles, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in `error` or `crashed` ends, and that a pooled view keeps node identity when its source alias is `root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error` status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a `reload()` that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay; a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted decides nothing when it finishes; a held replacement that loaded decides the demands it replaces; a replay's success keeps the error of a key whose later load threw; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady` option does nothing on a ready Collection, also after `fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()` is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories pin that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, that a later change drops a parked replacement that no longer matches, and that a replay that loaded only a released key stays open. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's `cleanup({ dependents: true })` and restart, and then subscribes again stays `cleaned-up`, while a live-query Collection restarts; the pooled oracle only checks views that stay mounted; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 031959fc0c..9f3f81a9ac 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -122,6 +122,9 @@ type SubsetDemand = { acquisition: SubsetAcquisitionRecord acquisitionState: `starting` | `active` | `detached` initialResult?: Deferred<void> + // A parked demand's wait in an open replay. An acquisition that replays + // it replaces the wait, which settles only with that replay. + parkedParticipant?: TruncateReplayParticipant // The demands this one replaces for its owner: their replay failures no // longer count once it loads (law 10: the latest load decides). replaces?: Array<SubsetDemand> @@ -132,12 +135,17 @@ type TruncateReplayAttempt = { setupComplete: boolean } +type TruncateReplayParticipant = { + demand: SubsetDemand + attempt: TruncateReplayAttempt +} + type TruncateReplayState = { syncRunGeneration: number publicationState: TruncatePublicationState /** Direct subscribers buffer the replacement here; delegated publication has no buffer. */ privateRows: Map<string | number, object> | undefined - pending: Set<{ demand: SubsetDemand; attempt: TruncateReplayAttempt }> + pending: Set<TruncateReplayParticipant> pendingSetups: number currentAttempt: TruncateReplayAttempt failures: Map<SubsetDemand, Error> @@ -550,6 +558,10 @@ export class CollectionSubscription } } const waiting = demand.initialResult + const parked = demand.parkedParticipant + demand.parkedParticipant = undefined + if (parked && replayState.pending.delete(parked)) + parked.attempt.pendingCount-- if (waiting) { // A replay discarded by a later reload leaves the caller waiting for // that reload's replay; cleanup rejects waiting callers itself. @@ -625,7 +637,7 @@ export class CollectionSubscription attempt: TruncateReplayAttempt, demand: SubsetDemand, result: LoadSubsetRequestResult, - ): void { + ): TruncateReplayParticipant | undefined { if ( this.truncateReplayState !== replayState || (replayState.currentAttempt !== attempt && @@ -633,7 +645,7 @@ export class CollectionSubscription attempt.pendingCount === 0) || !(result instanceof Promise) ) { - return + return undefined } // An older attempt can still accept returning startup work while setup or @@ -660,6 +672,7 @@ export class CollectionSubscription this.settleTruncateReplay(replayState, pending) }, ) + return pending } /** Stop obsolete logical demand from pinning a replay barrier. */ @@ -1142,7 +1155,7 @@ export class CollectionSubscription } void initialResult.promise.then(finish, finish) if (replayState && replayAttempt) - this.trackTruncateReplayParticipant( + demand.parkedParticipant = this.trackTruncateReplayParticipant( replayState, replayAttempt, demand, diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index 01145dcdfa..34ee998e3c 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -151,9 +151,8 @@ * on-demand Collection that fails its first load after the query is ready. * The window grammar settles all pending loads at once, and the lazy grammar * settles all, the oldest, or the newest (the overlapping-load lane owns - * other orders). The model does not see a demand parked behind a child whose - * first load failed: production makes no request for it, and the laws do not - * say whether it decides its keys (open in the coverage map). Per-row child Collections are not + * other orders). A demand parked behind a child whose first load failed makes + * no request, so the model predicts it from law 10 (maintainer decision). Per-row child Collections are not * in the tree; an include's node shows its source. Pooled histories do not * restart or clean up: a pooled view ends for good when its Collection is * cleaned up. The ordering check reads the observed query only, not its @@ -3883,6 +3882,14 @@ describe(`on-demand first load after query readiness`, () => { * adapter still serves, its rows, a replay failure awaiting judgment, or a * shown request error. A load that the query aborts without a replacement * leaves its key uncovered. + * + * One request the model does predict: while the Collection shows a failed + * first load, production parks a demand change's acquisition and sends no + * request (maintainer decision: model it). The model starts the acquisition + * that law 10 asks for, growth or churn, as a parked load + * (`parkDemandChange`). It is the latest load of its keys, joins and holds an + * open replay, and covers its keys. The next replay, or the Collection's + * restart, requests it, and those requests replace it. */ // One load the query requested, as the adapter saw it. @@ -3891,12 +3898,20 @@ describe(`on-demand first load after query readiness`, () => { // `superseded`: a newer truncate replaced the replay attempt that made it. It // still holds the replay's completion, decides nothing, and its rows apply // if it succeeds. +// `parked`: the query asked for these keys while its Collection showed a +// failed first load, so no request reached the adapter; the acquisition +// waits until the Collection restarts. type DemandLoad = { serial: number keys: Set<number> replay: boolean fromReplay: boolean superseded: boolean + parked?: boolean + // A parked churn replacement for every held key. A later demand change + // that holds other keys drops it (law 10: the replacement no longer + // matches the demand), and no request tells the driver. + replacement?: boolean } type QueryGate = `loading` | `ready` | `error` | `crashed` @@ -3920,9 +3935,8 @@ type Demand = { // Replay loads that failed in the current attempt, by serial; the replay // judges them when it settles. replayFailedLoads: Map<number, Set<number>> - // A load of the open replay succeeded, in any attempt, and the keys its - // loads loaded, with the serial of each key's latest such load. - replayLoaded: boolean + // The keys that loads of the open replay loaded, in any attempt, with the + // serial of each key's latest such load. replayLoadedKeys: Map<number, number> // Loads that a replay retired and the adapter still serves. As for a // truncate, a load started before a replay still holds the query's first @@ -3939,6 +3953,12 @@ type Demand = { // A replay started since the last report: the requests now reported are // its own. replayIssued: boolean + // Loads that failed: they cover nothing (law 10). + failedLoads: Set<number> + // The latest adapter request's serial, and how many parked acquisitions + // followed it; a parked acquisition is ordered between the two requests. + lastSerial: number + parks: number } // `serial` numbers the source's reported problems and written rows. @@ -3964,13 +3984,15 @@ function freshOnDemandModel(held: Iterable<number>): OnDemandModel { pending: [], replayFailedKeys: new Map(), replayFailedLoads: new Map(), - replayLoaded: false, replayLoadedKeys: new Map(), retiredInFlight: new Map(), requested: new Map(), replayAtFirstCommit: false, replayIssued: false, thrown: new Set(), + failedLoads: new Set(), + lastSerial: 0, + parks: 0, }, source: freshSource(), serial: 0, @@ -4053,10 +4075,12 @@ function settleDemandLoad( ) demand.failed.delete(key) } - if (load.replay) demand.replayLoaded = true settleReplay(model) return } + // A failed demand load covers nothing. A replay's own request keeps the + // coverage of the acquisition it replays, which the replay judges. + if (!load.fromReplay) demand.failedLoads.add(load.serial) // Only the latest load of a key the query still holds decides it. const held = [...load.keys].filter( (key) => demand.held.has(key) && latestFor(key), @@ -4083,21 +4107,6 @@ function settleReplay(model: OnDemandModel): void { entry.replay && (!entry.superseded || [...entry.keys].some((key) => demand.held.has(key))) if (demand.pending.some(holds)) return - // A demand parked behind a Collection whose first load failed made no - // request, but it is still held, and the replay waits for it. It ends when - // the Collection restarts and the demand loads, or when the demand - // retires. A key that a replay load settled for is judged instead. - const settledInReplay = (key: number) => - demand.replayLoadedKeys.has(key) || - demand.replayFailedKeys.has(key) || - [...demand.replayFailedLoads.values()].some((keys) => keys.has(key)) - if ( - model.source.gate === `error` && - [...demand.held].some( - (key) => !isLoaded(model, key) && !settledInReplay(key), - ) - ) - return // Law 10: a key's latest load decides it, so a replay load that loaded a // key after its failed load replaces that failure. for (const [key, serial] of demand.replayFailedKeys) @@ -4123,12 +4132,16 @@ function settleReplay(model: OnDemandModel): void { // The replacement succeeded: its request error clears, and so do the // errors of the keys it loaded again. A replay whose demand all retired // with none loaded is aborted instead, and its error stays. - if (open && demand.replayLoaded) { + // It succeeds as a replacement for the demand that remains (law 9), so + // it must have loaded some of that demand. + const loadedHeld = [...demand.replayLoadedKeys.keys()].some((key) => + demand.held.has(key), + ) + if (open && loadedHeld) { demand.replayFailed = false // A key's failure after its replay load stays: the later load decides. for (const [key, serial] of demand.replayLoadedKeys) if ((demand.failed.get(key) ?? 0) < serial) demand.failed.delete(key) - demand.replayLoaded = false demand.replayLoadedKeys = new Map() return } @@ -4150,6 +4163,19 @@ function settleReplay(model: OnDemandModel): void { // the query's own loads decide nothing either. function startReplay(model: OnDemandModel, cause: `reload` | `truncate`): void { const { demand } = model + // A reload replays the demand when it retires the run only if the query + // holds an acquisition: a held key with a load that did not throw, parked + // or not. Otherwise its first commit replays. + const holdsAcquisition = [...demand.requested].some( + ([serial, keys]) => + !demand.thrown.has(serial) && + [...keys].some((key) => demand.held.has(key)), + ) + // A replay requests parked demand too: its requests replace the parked + // acquisitions. + for (const load of demand.pending) + if (load.parked) demand.requested.delete(load.serial) + demand.pending = demand.pending.filter((load) => !load.parked) for (const load of demand.pending) { if (cause === `truncate` && load.replay) load.superseded = true else if (cause === `truncate` || !load.fromReplay) @@ -4159,23 +4185,11 @@ function startReplay(model: OnDemandModel, cause: `reload` | `truncate`): void { // not count for this one. demand.replayFailedKeys = new Map() demand.replayFailedLoads = new Map() - demand.replayLoaded = false demand.replayLoadedKeys = new Map() demand.pending = cause === `truncate` ? demand.pending.filter((load) => load.superseded) : [] if (cause === `truncate`) demand.requested = new Map() demand.replayAtFirstCommit = false - // A reload replays the demand when it retires the run only if the query - // holds an acquisition: a held key with a load that did not throw. - // Otherwise its first commit replays. - // Demand parked behind a failed first load is an acquisition too. - const holdsAcquisition = - (model.source.gate === `error` && demand.held.size > 0) || - [...demand.requested].some( - ([serial, keys]) => - !demand.thrown.has(serial) && - [...keys].some((key) => demand.held.has(key)), - ) if (cause === `reload` && !holdsAcquisition) { demand.replayAtFirstCommit = true return @@ -4198,22 +4212,78 @@ function changeDemand( kind: `parent` | `unparent`, key: number, ): void { - if (kind === `parent`) { - demand.held.add(key) - return + if (kind === `parent`) demand.held.add(key) + else { + demand.held.delete(key) + demand.failed.delete(key) } - demand.held.delete(key) - demand.failed.delete(key) // Releasing an owner removes both its current and its older work from // readiness (law 9): older replay work for keys the query no longer holds // holds nothing, even if the query asks for those keys again. - demand.pending = demand.pending.filter( - (load) => - !load.superseded || [...load.keys].some((held) => demand.held.has(held)), - ) + const released = (load: DemandLoad) => + ((load.superseded || load.parked) && + [...load.keys].every((held) => !demand.held.has(held))) || + (!!load.replacement && + (load.keys.size !== demand.held.size || + [...load.keys].some((held) => !demand.held.has(held)))) + for (const load of demand.pending) + if (load.parked && released(load)) demand.requested.delete(load.serial) + demand.pending = demand.pending.filter((load) => !released(load)) if (demand.held.size === 0) demand.replayFailed = false } +// A demand change while the Collection shows a failed first load starts the +// acquisition that law 10 asks for, but it parks without a request. Law 10: +// growth requests only the held keys that no live load covers (a failed or +// thrown load covers nothing); churn, a change that retires a key while it +// still has keys to request or more than one live load, requests one +// replacement for every held key. +function parkDemandChange( + model: OnDemandModel, + kind: `parent` | `unparent`, +): void { + const { demand } = model + if (model.source.gate !== `error` || demand.held.size === 0) return + const live = [...demand.requested].filter( + ([serial, keys]) => + !demand.thrown.has(serial) && + !demand.failedLoads.has(serial) && + [...keys].some((key) => demand.held.has(key)), + ) + const covered = new Set(live.flatMap(([, keys]) => [...keys])) + // A key whose own load failed is retried, also while a replay loads it. + const uncovered = [...demand.held].filter( + (key) => !covered.has(key) || demand.failed.has(key), + ) + const churn = kind === `unparent` && (uncovered.length > 0 || live.length > 1) + const keys = new Set(churn ? demand.held : uncovered) + if (keys.size === 0) return + const load: DemandLoad = { + serial: demand.lastSerial + ++demand.parks / 1000, + keys, + replay: demand.replayActive || demand.replayFailed, + fromReplay: false, + superseded: false, + parked: true, + replacement: churn, + } + demand.requested.set(load.serial, keys) + demand.pending.push(load) + if (load.replay) demand.replayActive = true +} + +// The Collection restarted after its failed first load: its parked +// acquisitions now request, as a replay of the detached demand. +function restartParked(model: OnDemandModel): void { + const { demand } = model + const parked = demand.pending.filter((load) => load.parked) + if (parked.length === 0) return + for (const load of parked) demand.requested.delete(load.serial) + demand.pending = demand.pending.filter((load) => !load.parked) + demand.replayActive = true + demand.replayIssued = true +} + // Requests the adapter saw since the last report. `aborted`: requests the // query aborted since then. `stillServed`: the adapter still serves the // request. `replayMade`: a truncate or a reload in this step made the @@ -4244,6 +4314,8 @@ function reportRequests( // replay makes fails with the replay. Every request is recorded before a // thrown one settles, so the replay's later requests still belong to it. const reported = requests.map((request) => { + demand.lastSerial = Math.max(demand.lastSerial, request.serial) + demand.parks = 0 const load: DemandLoad = { serial: request.serial, keys: request.keys, @@ -4312,10 +4384,7 @@ function expectedRequestError(demand: Demand): number | null { // holds is covered by a load the adapter still serves, by its rows, by a // replay failure awaiting judgment, or by the request error the query shows // for it. `served` comes from the driver's adapter calls, not from -// production state. Law-silent, recorded: a Collection whose first load -// failed parks new demand without a request (law 16 shows that problem on -// the Collection's node), so a key that never had a request error is covered -// while the Collection shows that failure. +// production state; a parked acquisition covers its keys without one. function expectHeldDemandCovered( model: OnDemandModel, served: Iterable<Set<number>>, @@ -4324,18 +4393,19 @@ function expectHeldDemandCovered( ): void { if (model.gate !== `ready`) return const { demand } = model - const live = new Set([...served].flatMap((keys) => [...keys])) + const live = new Set([ + ...[...served].flatMap((keys) => [...keys]), + ...demand.pending.flatMap((load) => (load.parked ? [...load.keys] : [])), + ]) const judged = new Set( [...demand.replayFailedLoads.values()].flatMap((keys) => [...keys]), ) - const parked = model.source.gate === `error` const uncovered = [...demand.held].filter( (key) => !live.has(key) && !isLoaded(model, key) && !judged.has(key) && - !(errorShown && (demand.failed.has(key) || demand.replayFailed)) && - !(parked && !demand.failed.has(key)), + !(errorShown && (demand.failed.has(key) || demand.replayFailed)), ) expect(uncovered, `${label}: held demand is covered`).toEqual([]) } @@ -4889,11 +4959,16 @@ function applyLazyStep( ): void { if (model.gate === `crashed`) return const { source } = model + const failedFirstLoad = source.gate === `error` switch (step.kind) { case `parent`: - case `unparent`: + case `unparent`: { + // Adding a held key or removing one not held changes no demand. + if (model.demand.held.has(step.key) === (step.kind === `parent`)) return changeDemand(model.demand, step.kind, step.key) + parkDemandChange(model, step.kind) return + } // Settle steps reach the model one load at a time, from the driver. case `settle-ok`: case `settle-fail`: @@ -4942,6 +5017,9 @@ function applyLazyStep( default: applySourceStep(model, source, `source`, { kind: step.kind }) } + // A Collection that leaves its failed first load restarts its parked + // demand. + if (failedFirstLoad && source.gate !== `error`) restartParked(model) projectSourceGate(model) } @@ -5903,6 +5981,82 @@ describe(`lazy join demand grammar`, () => { ], { holdChildReady: true }, )) + // A replay re-requests a demand parked behind a failed first load; that + // request replaces the parked wait, so the replay completes and publishes. + it(`a truncate replay re-requests parked demand and then publishes it`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-ok` }, + ], + { holdChildReady: true }, + )) + // Releasing a parked demand removes its parked acquisition, so a demand + // for the same key parks again. + it(`a parked demand released and asked for again parks again`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `parent`, key: 2 }, + ], + { holdChildReady: true }, + )) + // A replay's failed request keeps its acquisition's coverage, so releasing + // a parked key requests nothing more and the replay fails for the key it + // could not load again. + it(`a failed replay request still covers its key when a parked key retires`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 3 }, + ], + { holdChildReady: true }, + )) + // Law 10: a later demand change drops a parked replacement that no longer + // matches the held keys, so a replay failure it would have replaced + // stands. + it(`a demand change drops a parked replacement that no longer matches`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `unparent`, key: 3 }, + ], + { holdChildReady: true }, + )) + // Law 9: a replay succeeds as a replacement for the demand that remains; + // one that loaded only a key the query then released, while the + // remaining key's request threw, stays open. + it(`a replay that loaded only a released key stays open`, () => + runLazyHistory( + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `truncate` }, + { kind: `throw-next` }, + { kind: `settle-first-ok` }, + { kind: `unparent`, key: 1 }, + ], + { honorsAbort: false }, + )) const lazyReach = createOracleReach( `source-health.lazy-demand`, lazyReachStates,