diff --git a/.changeset/persisted-initial-render-split.md b/.changeset/persisted-initial-render-split.md deleted file mode 100644 index f6f1dc8569..0000000000 --- a/.changeset/persisted-initial-render-split.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@tanstack/db': patch -'@tanstack/db-sqlite-persistence-core': patch ---- - -Apps without a persisted Collection no longer bundle the persisted initial-render wait. The persistence adapter now supplies the wait, so a typical app is about 230 bytes smaller after gzip. diff --git a/.changeset/source-health-channel.md b/.changeset/source-health-channel.md new file mode 100644 index 0000000000..18b6414879 --- /dev/null +++ b/.changeset/source-health-channel.md @@ -0,0 +1,38 @@ +--- +'@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/rxdb-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 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.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`. + +Breaking changes and migration: + +| Before | After | +| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| hook `isError` | `isLoadingError` (first load failed) or `isCrashed` | +| — | `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 `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 0b56f39712..a59d1b7984 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -407,9 +407,17 @@ 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 +fails the first load (`status: 'error'`); after it, the rows stay and the +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 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 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 fcff38cf4d..c2a2f9735c 100644 --- a/docs/collections/indexed-db-collection.md +++ b/docs/collections/indexed-db-collection.md @@ -278,7 +278,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..01da65caff 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,8 +682,14 @@ 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. + +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.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 new file mode 100644 index 0000000000..c22e4989a1 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/HEALTH-CANDIDATE.md @@ -0,0 +1,160 @@ +# 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.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. + +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) + +- 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 `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. + +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 health tree + +Every live-query Collection exposes `health`, and every framework hook returns +it beside `data`: + +```ts +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 // 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`. `health.hasErrors` reports anything anywhere in the tree. + +### Effects + +`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`. + +### 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..68ac6dbf9e --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -0,0 +1,300 @@ +# 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.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. + +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. `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`). + +## 1. Decisions (maintainer, 2026-10-13) + +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 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 + 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 `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 + 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 sync adapter after Collection readiness. +- **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, + message, code?, at, cause }`. Everything except `cause` is JSON-safe. +- **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 + (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 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 + 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 `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.** `health` 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 sync run + throws. Keep `error` → `cleaned-up` and `idle`. +- [ ] `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 sync run 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`, + `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`: + - [ ] 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-readiness Collection health does not change the live query's status or + rows; + - [ ] 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 + 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 Collection readiness. +- [ ] `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 + 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-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 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 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 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, post-readiness health). +- [ ] **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 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 + `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. +- [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 + `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 `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 `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-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 + 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 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 `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). +- [ ] 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 Collection readiness; `reportError` and `reportHealthy` after readiness; + never drop rows). +- [ ] `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/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. +- [ ] `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, 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 + `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 Collection readiness, failures no longer move a Collection or a live + 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 + 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 health 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 `health` 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-readiness 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..57396754ca --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/README.md @@ -0,0 +1,164 @@ +# 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 +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..d9ef5c2fb6 --- /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 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 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 + +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..7f24cb650e --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/USER-DOCS-DRAFT.md @@ -0,0 +1,257 @@ +# 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.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. + +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. + +--- + +## 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? | `health` (the health 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 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. + +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 `health` instead. + +> **Important:** `isError` only describes the initial load. To show a +> "data may be out of date" banner, check `health.hasErrors`. + +```tsx +function TaskList() { + const { data, isLoading, isError, health } = useLiveQuery((q) => + q + .from({ task: tasks }) + .join({ user: users }, ({ task, user }) => eq(task.assigneeId, user.id)), + ) + + if (isLoading) return + if (isError) return + + return ( + <> + {health.hasErrors && } + {data.map((row) => ( + + ))} + + ) +} +``` + +### The health tree + +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 HealthNode = { + 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 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 + 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. + +`health` is plain data apart from `cause`, so you can log or send it: + +```ts +useEffect(() => { + if (health.hasErrors) reportToMonitoring(JSON.parse(JSON.stringify(health))) +}, [health]) +``` + +Server-side rendering does not transfer `health`. 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: (health) => { + if (health.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 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 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 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()`. +- **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 `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/ErrorSurface4.tla b/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla new file mode 100644 index 0000000000..a43cdf6a66 --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/ErrorSurface4.tla @@ -0,0 +1,399 @@ +---------------------------- 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" +\* 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 == + [][\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..9c3d6aadb9 --- /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` 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 + 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. `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 `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 + 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 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` + 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..f81c8bf86a --- /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`. +- `health`: a tree, one node per query alias plus a root node for the query. Each node: + { alias, kind: 'source'|'live-query'|'include', status, hasErrors, error | null, sources }. + A source node carries at most one of: + - `failureReason`: the latest problem while the adapter keeps trying (like TanStack Query's + `failureReason` during retries); + - `error`: the adapter stopped (`fail`), or the gate failed. + Each value is { name, message, code?, at, cause }. A node also shows `reloading` while a + reload's new run is in flight. The query's own node carries a request `error`. +- `collection.health`: the Collection's current after-ready error, or null. +- `collection.reload()`: start a new sync run while keeping the current rows (see R7). +- `collection.cleanup()`: throws while the Collection has subscribers. The app can list a + Collection's subscribers (live queries, Effects, raw subscriptions) and ask cleanup to clean + up those dependents first. + +## Rules +R1 Gate. Each sync run: loading -> ready | error | crashed. An adapter that keeps retrying + before its first ready reports nothing (status stays loading). 'error' (gave up before the + first ready) is final for the run. +R2 Crash. 'crashed' = the Collection can no longer serve reads or accept writes: local + storage broken (closed IndexedDB, a failed local write or restore) or an internal invariant + failure. Final for the run; ends every live query and Effect over it. The cause may or may + not persist into a new run. +R3 Query gate. Until a live query is ready, it takes its sources' worst gate + (crashed > error > loading > ready). After it is ready, only 'crashed' propagates. +R4 After ready, a failure never changes status or removes rows. It appears on the node that + owns it: + - source node, sync problem: `failureReason` (adapter still trying; clears itself) or + `error` (adapter stopped; rows frozen until reload); + - a source whose new sync run fails before its first ready while the query is ready + (an on-demand source loading late, or a source being reloaded): node `error`; the query + stays ready; + - query's own node, request error: a request for more data (next page, newly joined rows) + failed; clears when that data loads or is no longer needed. +R5 Adapter verbs: markReady(); reportError(e) = something went wrong, still running + (sets `failureReason`); fail(e) = stopped (before ready -> status 'error'; after -> node + `error`); reportHealthy() clears `failureReason`. A node `error` clears only when a + reload's new run commits. +R6 Tree identity is stable while content is unchanged; every field except `cause` is JSON. +R7 Reload. reload() starts a new sync run like a sync-side truncate. Nothing visible changes + until the new run's first commit or failure: rows, status ('ready' or 'error'), and the + current error stay, and the node shows `reloading`. The first commit replaces all rows + atomically and clears the node's problem (an 'error' status becomes 'ready'). If the new + run fails first, the old rows and status stay and the node shows the new `error`. + reload() while a reload is in flight returns that reload's promise. reload() on a + 'crashed' Collection throws: a crash is not recovered in place; recovery is a cascade + cleanup, which discards all state, and a fresh start (of the same object or a new one). reload() during 'loading' starts a new run; commits from the old run are + ignored. A failed request is re-issued by the new run. +R9 Live-query reload. reload() on a live query in 'error' reloads each of its sources in + 'error' or with a node `error`. Its status stays 'error' while any source still has a + failure, including a source whose reload is in flight (it keeps its 'error' gate). Once no + source has a failure, the query moves to 'loading', or straight to 'ready'. Adapters retry inside their + first load (for example query-db's retry or the queryFn) before calling fail(). +R8 Persisted Collections. A completed local restore of previously synced rows opens the gate + (status 'ready') even if the remote has not connected; remote problems after that are + reported (R4/R5), including before the remote's first sync. A remote that fails before the + restore finishes waits for the restore: synced rows -> 'ready' with node `error`; none -> + 'error'. A failed restore is a crash. An empty restore of a Collection that never synced + does not open the gate. + +## Invariants +I1 isLoadingError is never true after the query has shown data in its run. +I2 After a query is ready, its status is 'ready' or 'crashed' until it is recreated. +I3 Status 'ready' implies rows are shown; no after-ready failure or reload blanks rows. +I4 From what the app can observe, it can pick one remedy: wait, retry the action, reload, + report broken storage. +I5 No silent recovery or silent degradation: 'error' becomes 'ready' only through a reload's + commit; 'crashed' never becomes 'ready' without a cleanup in between; a failing remote is never + shown as healthy; a reload never shows 'loading' in place of 'error'. +I6 'crashed' always means broken local storage or an invariant failure. +I7 Rows shown never come from a superseded run. +I8 'ready' never means "no rows" when the truth is "unknown". 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..b827a2d180 --- /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 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 + 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..39a21407ae --- /dev/null +++ b/docs/contributing/designs/source-failure-and-health/error-surface/user-docs-draft.md @@ -0,0 +1,321 @@ +--- +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 `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 `health`, and the rows stay. + +```tsx +function TaskList() { + const { + data, + isLoading, + isLoadingError, + isCrashed, + isSyncError, + health, + 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 `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`. + +`isError` is deprecated. It is `true` for `error` and `crashed`. Use `isLoadingError` and +`isCrashed`, which need different handling. + +### Sync errors: the `health` tree + +`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. + +```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({ health, onReload }: { health: HealthNode; onReload: () => void }) { + const [first] = findProblems(health) // nodes with a problem, in query order + if (first?.failureReason?.code === '401') return + if (first?.error) return + return +} +``` + +`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(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 +`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.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 `health` 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, health, fetchNextPage } = useLiveInfiniteQuery(/* ... */) + +{ + health?.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 +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 +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 `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`. +- **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 +`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 +`health` 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 `health` | +| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `health`, `reload()` | 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 diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index e45217b638..19c11c46e1 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -31,15 +31,15 @@ production queues, caches, or semantic helpers merely to share their names. | 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. | | deferred sync transaction | A committed sync transaction that a wrapping sync has not handed to the Collection yet, because an earlier durable write is still running. Like a queued sync transaction, it holds settled optimistic rows on its keys until it applies. | Queued or accepted sync transaction. | | held optimistic row | A successful transaction's optimistic row kept only while a committed sync transaction that has not applied touches its key, so the drop and that transaction publish together. The transaction may be queued in the Collection or deferred behind a wrapping sync's durable step. 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. | | 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 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 | 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. | | 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. | @@ -50,15 +50,33 @@ 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 and a provider-qualified `provider session` for a remote lifecycle. +## Collection status and health terms + +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 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 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. 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. | + ## Persistence coordination terms | Term | Meaning | @@ -210,6 +228,11 @@ 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 **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 coverage, content, history, or a non-promise operation only when no stronger diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a085dd75d9..4a5b16b47a 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 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. | @@ -93,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 @@ -641,7 +643,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), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.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 setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. 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 campaign is omitted until the fix, 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 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. | @@ -2567,37 +2569,26 @@ 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, 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 +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 @@ -3684,7 +3675,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/errors.md b/docs/errors.md index fcfdcce0c4..66c3801d6e 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 not recovered in place; clean it up with { dependents: true } and start it again. +``` + + + +## 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` @@ -1473,7 +1503,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 +1873,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 ``` @@ -2469,3 +2499,43 @@ Development builds show this message, with `${...}` replaced by values: ```text A windowed or findOne result row has no include route ``` + + + +## Error 245: `collection/lifecycle.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +${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. +``` + + + +## Error 246: `query/pooled-live-query.ts` + +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/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(options): InjectLiveQueryResult; ``` -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(options): InjectConditionalLiveQueryResult; ``` -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(queryFn): InjectLiveQueryResult; ``` -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(queryFn): InjectConditionalLiveQueryResult; ``` -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(config): InjectLiveQueryResult; ``` -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(liveQueryCollection): InjectLiveQueryResultWithCollection; ``` -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(liveQueryCollection): InjectLiveQueryResultWithSingleResultCollection; ``` -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; +``` + +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; +``` + +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; +``` + +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; +``` + +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; +``` + +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; +``` + +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; +``` + +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; +``` + +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; +``` + +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>; ``` -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; +``` + +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; ``` -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; +isCrashed: Signal; ``` -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; ``` -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; ``` -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; +isLoadingError: Signal; ``` -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; ``` -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; -``` - -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; +isSyncError: Signal; ``` 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[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; ``` -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; ``` -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; +``` + +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; ``` -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; +isCrashed: Signal; ``` -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; ``` -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; ``` -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; +isLoadingError: Signal; ``` -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; ``` -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; -``` +#### 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; +isSyncError: Signal; ``` -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>; ``` -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; ``` -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; +``` + +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; ``` -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; +isCrashed: Signal; ``` -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; ``` -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; ``` -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; +isLoadingError: Signal; ``` -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; ``` -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; -``` - -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; +isSyncError: Signal; ``` -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; ``` -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 = Omit, "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 484dd6b56d..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. 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 `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(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -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( deps?): UseLiveInfiniteQueryReturn; ``` -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(queryFn, deps?): object; +function useLiveQuery(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[K] }, string | number, { -}>; -``` - -#### data - -```ts -data: InferResultType; -``` - -#### 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[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
Loading...
-if (isError) return
Error: {status}
+if (isLoadingError) return
Error: {status}
return (
    @@ -193,10 +114,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(queryFn, deps?): object; +function useLiveQuery(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[K] }, string | number, { -}, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType | 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[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
    Loading...
    -if (isError) return
    Error: {status}
    +if (isLoadingError) return
    Error: {status}
    return (
      @@ -384,7 +222,7 @@ return ( ## Call Signature ```ts -function useLiveQuery(queryFn, deps?): object; +function useLiveQuery(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[K] }, string | number, { -}, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType | 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[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
      Loading...
      -if (isError) return
      Error: {status}
      +if (isLoadingError) return
      Error: {status}
      return (
        @@ -578,10 +333,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(queryFn, deps?): object; +function useLiveQuery(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> - | 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 | 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
        Loading...
        -if (isError) return
        Error: {status}
        +if (isLoadingError) return
        Error: {status}
        return (
          @@ -777,10 +452,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(queryFn, deps?): object; +function useLiveQuery(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> - | Collection<{ [K in string | number | symbol]: ResultValue[K] }, string | number, { -}, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[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 | 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[K] }> - | Map - | 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
          Loading...
          -if (isError) return
          Error: {status}
          +if (isLoadingError) return
          Error: {status}
          return (
            @@ -987,10 +577,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(config): object; +function useLiveQuery(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[K] }, string | number, { -}>; -``` - -#### data - -```ts -data: InferResultType; -``` - -#### 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[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
            Loading...
            -if (isError) return
            Something went wrong
            +if (isLoadingError) return
            Something went wrong
            if (!isReady) return
            Preparing...
            return
            {data.length} items loaded
            @@ -1131,10 +642,10 @@ return
            {data.length} items loaded
            ## Call Signature ```ts -function useLiveQuery(config): object; +function useLiveQuery(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[K] }, string | number, { -}, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType | 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[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
            Loading...
            -if (isError) return
            Error: {status}
            +if (isLoadingError) return
            Error: {status}
            return (
              @@ -1314,10 +742,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(config, deps?): object; +function useLiveQuery(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[K] }, string | number, { -}>; -``` - -#### data - -```ts -data: InferResultType; -``` - -#### 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[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
              Loading...
              -if (isError) return
              Error: {status}
              +if (isLoadingError) return
              Error: {status}
              return (
                @@ -1499,10 +848,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(config, deps): object; +function useLiveQuery(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[K] }, string | number, { -}, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[K] }> - | undefined; -``` - -#### data - -```ts -data: InferResultType | 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[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
                Loading...
                -if (isError) return
                Error: {status}
                +if (isLoadingError) return
                Error: {status}
                return (
                  @@ -1688,10 +954,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(liveQueryCollection): object; +function useLiveQuery(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; -``` - -#### 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; -``` - -#### 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
                  Loading...
                  -if (isError) return
                  Error loading data
                  +if (isLoadingError) return
                  Error loading data
                  return
                  {data.map(item => )}
                  ``` @@ -1834,10 +1022,10 @@ return
                  {data.map(item => )}
                  ## Call Signature ```ts -function useLiveQuery(liveQueryCollection): object; +function useLiveQuery(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> & 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; -``` - -#### 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
                  Loading...
                  -if (isError) return
                  Error: {status}
                  +if (isLoadingError) return
                  Error: {status}
                  return (
                    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(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[ data: InferResultType; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -162,7 +168,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(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[ data: InferResultType; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -307,7 +319,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(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[ data: InferResultType; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -458,7 +476,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(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; data: TResult[]; ``` +#### health + +```ts +health: HealthNode | undefined; +``` + #### state ```ts @@ -610,7 +634,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(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 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; ``` -### 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 = 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 & 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; ``` -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; ``` -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(queryFn): Accessor> & object; +function useLiveQuery(queryFn): Accessor> & 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 (
                    Loading...
                    - +
                    Error: {todosQuery.status}
                    @@ -106,10 +106,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(queryFn): Accessor> & object; +function useLiveQuery(queryFn): Accessor> & 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 (
                    Loading...
                    - +
                    Error: {todosQuery.status}
                    @@ -206,7 +206,7 @@ return ( ## Call Signature ```ts -function useLiveQuery(config): Accessor> & object; +function useLiveQuery(config): Accessor> & 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 (
                    Loading...
                    - +
                    Something went wrong
                    @@ -275,10 +275,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(liveQueryCollection): Accessor & object; +function useLiveQuery(liveQueryCollection): Accessor & 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 (
                    Loading...
                    - +
                    Error loading data
                    @@ -347,10 +347,10 @@ return ( ## Call Signature ```ts -function useLiveQuery(liveQueryCollection): Accessor & object; +function useLiveQuery(liveQueryCollection): Accessor & 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 (
                    Loading...
                    - +
                    Error: {todosQuery.status}
                    diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index 5eeea14c81..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. 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.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(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -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( deps?): UseLiveInfiniteQueryReturn; ``` -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(queryFn, deps?): UseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue[K] }, InferResultType>; ``` -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} //
                    Loading...
                    -// {:else if todosQuery.isError} +// {:else if todosQuery.isLoadingError} //
                    Error: {todosQuery.status}
                    // {:else} //
                      @@ -135,7 +135,7 @@ const todosQuery = useLiveQuery((q) => function useLiveQuery(queryFn, deps?): ConditionalUseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue[K] }, InferConditionalResultType>; ``` -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} //
                      Loading...
                      -// {:else if todosQuery.isError} +// {:else if todosQuery.isLoadingError} //
                      Error: {todosQuery.status}
                      // {:else} //
                        @@ -261,7 +261,7 @@ const todosQuery = useLiveQuery((q) => function useLiveQuery(config, deps?): UseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue[K] }, InferResultType>; ``` -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} //
                        Loading...
                        -// {:else if itemsQuery.isError} +// {:else if itemsQuery.isLoadingError} //
                        Something went wrong
                        // {:else if !itemsQuery.isReady} //
                        Preparing...
                        @@ -334,7 +334,7 @@ const itemsQuery = useLiveQuery({ function useLiveQuery(liveQueryCollection): UseLiveQueryReturnWithCollection; ``` -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} //
                        Loading...
                        -// {:else if queryResult.isError} +// {:else if queryResult.isLoadingError} //
                        Error loading data
                        // {:else} // {#each queryResult.data as item (item.id)} @@ -417,7 +417,7 @@ const queryResult = useLiveQuery(sharedQuery) function useLiveQuery(liveQueryCollection): UseLiveQueryReturnWithCollection; ``` -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} //
                        Loading...
                        -// {:else if todosQuery.isError} +// {:else if todosQuery.isLoadingError} //
                        Error: {todosQuery.status}
                        // {:else} //
                          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; ``` -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; ``` -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; ``` -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; ``` -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 = Omit, "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; ``` -### 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 = Omit, "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 = LiveQueryCollectionConfig & 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 db8cddcbb9..cf9ef93de9 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 `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(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -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( deps?): UseLiveInfiniteQueryReturn; ``` -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(queryFn, deps?): UseLiveQueryReturn; ``` -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: //
                          Loading...
                          -//
                          Error: {{ status }}
                          +//
                          Error: {{ status }}
                          //
                            //
                          • {{ todo.text }}
                          • //
                          @@ -95,7 +95,7 @@ const { data, isLoading, isError, status } = useLiveQuery((q) => function useLiveQuery(queryFn, deps?): ConditionalUseLiveQueryReturn; ``` -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: //
                          Loading...
                          -//
                          Error: {{ status }}
                          +//
                          Error: {{ status }}
                          //
                            //
                          • {{ todo.text }}
                          • //
                          @@ -181,7 +181,7 @@ const { data, isLoading, isError, status } = useLiveQuery((q) => function useLiveQuery(config, deps?): UseLiveQueryReturn; ``` -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: //
                          Loading...
                          -//
                          Something went wrong
                          +//
                          Something went wrong
                          //
                          Preparing...
                          //
                          {{ data.length }} items loaded
                          ``` @@ -249,7 +249,7 @@ const { data, isLoading, isReady, isError } = useLiveQuery({ function useLiveQuery(liveQueryCollection): UseLiveQueryReturnWithCollection; ``` -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: //
                          Loading...
                          -//
                          Error loading data
                          +//
                          Error loading data
                          //
                          // //
                          @@ -328,7 +328,7 @@ const { data, isLoading, isError } = useLiveQuery(sharedQuery) function useLiveQuery(liveQueryCollection): UseLiveQueryReturnWithSingleResultCollection; ``` -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: //
                          Loading...
                          -//
                          Error: {{ status }}
                          +//
                          Error: {{ status }}
                          //
                            //
                          • {{ todo.text }}
                          • //
                          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; -``` - -Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) - -*** - ### fetchNextPage() ```ts fetchNextPage: () => Promise; ``` -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; ``` -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; +``` + +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; +isCrashed: ComputedRef; ``` 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; ``` -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; ``` -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; +isLoadingError: ComputedRef; ``` -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; +isSyncError: ComputedRef; ``` Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L66) -*** - -### pages - -```ts -pages: ComputedRef[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; +pageParams: ComputedRef; ``` -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; +pages: ComputedRef[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, 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; ``` -Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L79) - -*** - -### error - -```ts -error: ComputedRef; -``` - -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; ``` -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; ``` -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; +``` + +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; ``` -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; +isCrashed: ComputedRef; ``` -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; ``` -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; ``` -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; ``` -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; +isLoadingError: ComputedRef; ``` -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; ``` -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; +isSyncError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L91) - -*** - -### pages - -```ts -pages: ComputedRef; -``` +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; +pageParams: ComputedRef; ``` -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; +pages: ComputedRef; ``` -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>; ``` -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; ``` -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; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L69) + +*** + +### isCleanedUp + +```ts +isCleanedUp: ComputedRef; +``` + +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L66) + +*** + +### isCrashed + +```ts +isCrashed: ComputedRef; +``` + +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; +``` + +Defined in: [useLiveQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L61) + +*** + +### isLoading + +```ts +isLoading: ComputedRef; +``` + +Defined in: [useLiveQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L59) + +*** + +### isLoadingError + +```ts +isLoadingError: ComputedRef; +``` + +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; +``` + +Defined in: [useLiveQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveQuery.ts#L60) + +*** + +### isSyncError + +```ts +isSyncError: ComputedRef; +``` + +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; +``` + +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, { [K in string | number | symbol]: ResultValue[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>; ``` -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; +``` -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; ``` -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; +isCrashed: ComputedRef; ``` -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; ``` -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; ``` -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; +isLoadingError: ComputedRef; ``` -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; ``` -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; +isSyncError: ComputedRef; ``` -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; -``` +#### 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[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; ``` -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, 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; ``` -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; +``` + +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; ``` -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; +isCrashed: ComputedRef; ``` -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; ``` -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; ``` -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; +isLoadingError: ComputedRef; ``` -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; ``` -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; +isSyncError: ComputedRef; ``` -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; -``` +#### 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>; ``` -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; ``` -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; +``` + +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; ``` -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; +isCrashed: ComputedRef; ``` -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; ``` -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; ``` -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; +isLoadingError: ComputedRef; ``` -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; ``` -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; +isSyncError: ComputedRef; ``` -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; -``` +#### 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; ``` -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 = Omit, "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/collection-options-creator.md b/docs/guides/collection-options-creator.md index 89df6356a9..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, markError, collection } = params + const { begin, write, commit, markReady, fail } = params // 1. Initialize connection to your sync engine const connection = initializeConnection(config) @@ -147,10 +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. - // Only initial startup owns collection readiness. A later refetch - // failure must keep the last ready snapshot usable. - if (collection.status === 'loading') markError(error) + // No usable initial snapshot exists in this run, and this example does + // not retry. After readiness, report failures with reportError() instead. + fail(error) } } @@ -174,8 +173,52 @@ 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. **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 + +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 `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. +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 }) => { + 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 (!willRetry) fail(error) + else if (ready) reportError(error) + }, + }) + return () => stream.close() +} +``` + `commit()` accepts the transaction and returns `true`. An accepted transaction always applies, in commit order. While an optimistic transaction is persisting, @@ -998,8 +1041,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 `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 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 d64084fd76..7784eb9bc8 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -71,7 +71,13 @@ 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 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 +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" @@ -81,26 +87,26 @@ import { useLiveQuery } from "@tanstack/react-db" const syncedCollection = createCollection( queryCollectionOptions({ queryClient, - queryKey: ['synced-data'], + queryKey: ["synced-data"], queryFn: fetchData, getKey: (item) => item.id, + retry: 3, }) ) -// 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, isSyncError } = useLiveQuery((q) => + q.from({ item: syncedCollection }) + ) + return ( <> - {isError && errorCount > 3 && ( + {isSyncError && ( Unable to sync. Showing cached data. - @@ -111,17 +117,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 `failureReason` 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 it. ## Incremental Subset Load Errors @@ -178,7 +177,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 @@ -186,41 +186,284 @@ settle, the newest attempt decides the result, and subscribers receive the replacement only when that attempt succeeds. Cleanup rejects window moves that are waiting for replay with `AbortError`. -## Collection Status and Error States +## 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 `health`. -Collections track their status and transition between states: +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 `health`, +and the rows stay. ```tsx +import { eq, findProblems } from "@tanstack/db" import { useLiveQuery } from "@tanstack/react-db" -const TodoList = () => { - const { data, status, isError, isLoading, isReady } = useLiveQuery( - (query) => query.from({ todos: todoCollection }) +function TaskList() { + const { + data, + isLoading, + isLoadingError, + isCrashed, + isSyncError, + health, + collection, + } = useLiveQuery((q) => + q + .from({ task: tasks }) + .join({ user: users }, ({ task, user }) => eq(task.assigneeId, user.id)) ) - if (isError) { - return
                          Collection is in error state
                          - } + if (isLoading) return + if (isLoadingError) return collection.reload()} /> + if (isCrashed) return - if (isLoading) { - return
                          Loading...
                          - } + 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. 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. | - return
                          {data?.map(todo =>
                          {todo.text}
                          )}
                          +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 + `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. + +`isError` is removed. Use `isLoadingError` and `isCrashed`, which need +different handling. + +### Sync errors: the `health` tree + +`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 `health` is `undefined`. + +```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: [] }, + ], } ``` -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 +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, or the first load failed. 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. 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 +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 HealthNode } from "@tanstack/db" + +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 + if (first?.error) return + return +} +``` + +`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`. + +A Collection used twice in one query, for example in a self-join, has one node +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 + +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.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 `health` 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, health, fetchNextPage } = useLiveInfiniteQuery(/* ... */) + +{ + health?.error && ( + + ) +} +``` + +### 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 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. + +On a live query, `reload()` reloads each of its sources that has an `error`, +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. +useEffect(() => { + if (!isLoadingError) return + const id = setInterval(() => collection.reload().catch(() => {}), 30_000) + return () => clearInterval(id) +}, [isLoadingError, collection]) +``` + +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 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 }) +await todos.preload() // a fresh start; mount the queries again +``` + +### 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 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 `onHealthChange(health)` with the same +tree a live query returns, and the Effect keeps running. + +```ts +createEffect({ + query: (q) => q.from({ order: orders }), + onBatch: (events) => { + /* ... */ + }, + onHealthChange: (health) => { + if (health.hasErrors) pauseAutomations() + else resumeAutomations() + }, +}) +``` ### 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 +`health` from the hook, as in TanStack Query. ```tsx import { useLiveSuspenseQuery } from "@tanstack/react-db" @@ -228,13 +471,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, isSyncError } = useLiveSuspenseQuery((query) => + query.from({ todos: todoCollection }) ) // data is always defined here - return
                          {data.map(todo =>
                          {todo.text}
                          )}
                          + return ( + <> + {isSyncError &&
                          Todos may be out of date.
                          } + {data.map((todo) => ( +
                          {todo.text}
                          + ))} + + ) } const App = () => ( @@ -246,9 +495,66 @@ 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. +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. +- 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 + +| 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` | 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 `health` | +| query-db `utils.lastError`, `isError`, `errorCount`, `clearError()` | `health`, `reload()` | ## Transaction Error Handling @@ -363,7 +669,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" @@ -532,11 +838,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 -- 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 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 @@ -591,64 +896,38 @@ 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: +### Reload or Recreate -```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: - -```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 +start it again. See [When a Collection crashes](#when-a-collection-crashes). ### 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 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, isError } = useLiveQuery((query) => + const { data, health, collection } = useLiveQuery((query) => query.from({ todos: todoCollection }) ) + const [problem] = health ? findProblems(health) : [] return (
                          - {isError && ( -
                          Sync failed, but you can still view cached data
                          + {problem?.error && ( +
                          + Sync stopped.{" "} + +
                          )} - {data?.map(todo => )} + {problem?.failureReason &&
                          Reconnecting. Showing saved data.
                          } + {data.map((todo) => )}
                          ) } @@ -819,7 +1098,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 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 @@ -869,7 +1148,8 @@ const todosQuery = createLiveQueryCollection((query) => ) const TodoApp = () => { - const { data, isError, isLoading } = useLiveQuery(todosQuery) + const { data, isLoadingError, isCrashed, isLoading, isSyncError } = + useLiveQuery(todosQuery) const handleAddTodo = async (text: string) => { try { @@ -892,21 +1172,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 (
                          -
                          Collection error - data may be stale
                          - +
                          Todos could not load
                          +
                          ) } @@ -917,10 +1199,11 @@ const TodoApp = () => { return (
                          + {isSyncError &&
                          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..435fd46e85 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 `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 `health`: + +```tsx +function UserList() { + const { data, isLoading, isError, health } = useLiveQuery((q) => + q.from({ user: usersCollection }), + ) + + if (isLoading) return
                          Loading...
                          + if (isError) return
                          Error loading users
                          + + return ( + <> + {health?.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 `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) +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 `health` are `undefined` - `isEnabled` is `false` - `isReady` is `true` - `isLoading`, `isIdle`, `isError`, and `isCleanedUp` are all `false` @@ -2320,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` | `(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`. | ### Delta Events @@ -2441,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 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 5ea40eae44..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.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 `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 ca0ccf2cc5..a5827ee701 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.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. -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. +Place `TodosScreen` inside an [Error Boundary](./error-handling.md#using-suspense-and-error-boundaries-react) to handle a failed first load or a crash. -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. - -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 @@ -356,7 +350,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 `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; ``` -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; ``` -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(config): CollectionImpl; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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 | 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; ``` -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>; ``` -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>; ``` -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[]; ``` -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]>; ``` -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; +cleanup(options?): Promise; ``` -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(indexCallback, config): BaseIndex; ``` -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, 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; ``` -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]>; ``` -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; ``` -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; ``` -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(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(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(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(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; ``` -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; +``` + +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>>; ``` -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[]>; ``` -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>; ``` -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(event, timeout?): Promise; ``` -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(options): Collection, TKey, TUtils, T, InferSchemaInput> & 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(options): Collection, TKey, Exclude, T, InferSchemaInput> & 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(options): Collection, TKey, TUtils, T, InferSchemaInput> & 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(options): Collection, TKey, TUtils, T, InferSchemaInput> & 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(options): Collection & 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(options): Collection & 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(options): Collection & 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(options): Collection & 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(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(collection, options): LiveQueryObserver; ``` -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(collection, options): LiveQueryWindowController; ``` -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; ``` -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(input): ResolvedLiveQueryWindowInput; ``` -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(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(sync, factory): CollectionSyncConfigWithFactory; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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 | 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; ``` -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>; ``` -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>; ``` -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[]; ``` -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]>; ``` -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; +cleanup(options?): Promise; ``` -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(indexCallback, config): BaseIndex; ``` -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, 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; ``` -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]>; ``` -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; ``` -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; ``` -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(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(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(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(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; ``` -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; +``` + +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>>; ``` -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[]>; ``` -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>; ``` -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(event, timeout?): Promise; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; +``` + +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; +``` + +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>; ``` -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]>; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; -``` - -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 | 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; ``` -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; ``` -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; ``` -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; ``` -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 | 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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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(config): CollectionConfig, TKey, T, QueryCollectionUtils, TKey, InferSchemaInput, 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(config): CollectionConfig> & 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(config): CollectionConfig, TKey, T, QueryCollectionUtils, TKey, InferSchemaInput, 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(config): CollectionConfig> & 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; -``` - -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; ``` -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; ``` -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; ``` -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; ``` -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; ``` -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 = (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 = | DeleteKeyMessage; ``` -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 = ChangeMessage, 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 = Pick, | 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 = CollectionConfig & 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 = Omit, "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 = (params) => Promise; ``` -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 = 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; ``` -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>; ``` -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 = }; ``` -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 = | QueryBuilder; ``` -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]; ``` -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 = (params) => Promise; ``` -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 = 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; ``` -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>; ``` -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; ``` -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 = (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 & 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 = Omit & Partial>; ``` -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; ``` -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>; ``` -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 = | DeleteKeyMessage & 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 = }; ``` -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 = 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 = StandardSchema; ``` -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 = 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; ``` -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[]>; ``` -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; ``` -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 = 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 = (params) => Promise; ``` -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 = 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; ``` -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>; ``` -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 extends BuiltIns ? T : T extends (...arguments_) => unknown ? object extends WritableObjectDeep ? T : HasMultipleCallSignatures extends true ? T : (...arguments_) => ReturnType & WritableObjectDeep : T extends ReadonlyMap ? WritableMapDeep : T extends ReadonlySet ? WritableSetDeep : T extends ReadonlyArray ? WritableArrayDeep : T extends object ? WritableObjectDeep : 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 8464571ca5..6d73c60991 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.health() → health tree; health()?.hasErrors means data may be stale // query.isIdle(), query.isCleanedUp() (seldom used) // query.state() → Map // query.collection() → Collection | null diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 4f6b0f1c6d..2cf2bc0b41 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -19,11 +19,11 @@ import type { CollectionStatus, Context, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -38,32 +38,41 @@ 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 */ 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 - /** 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. Only a cascade cleanup and a fresh start recover. */ + 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 `health`. */ + isSyncError: Signal + /** A signal containing the health tree for the query and its dependencies */ + health: 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 = @@ -82,38 +91,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 } 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 } export function injectLiveQuery< @@ -226,8 +217,7 @@ export function injectLiveQuery(opts: any) { const value = statusValue() return value === `idle` && !collection() ? `disabled` : value }) - const persistedStatus = signal(`unavailable`) - const persistedError = signal(undefined) + const health = signal(undefined) // Returns single item for singleResult collections, array otherwise const data = computed(() => { @@ -251,8 +241,7 @@ export function injectLiveQuery(opts: any) { internalData.set(newData) const snapshot = observer.getSnapshot() statusValue.set(snapshot.status) - persistedStatus.set(snapshot.persistedStatus) - persistedError.set(snapshot.persistedError) + health.set(snapshot.health) } let unsub: (() => void) | null = null @@ -267,8 +256,7 @@ export function injectLiveQuery(opts: any) { // Handle null collection (disabled query) if (!currentCollection) { statusValue.set(`disabled` as const) - persistedStatus.set(`unavailable`) - persistedError.set(undefined) + health.set(undefined) state.set(new Map()) internalData.set([]) cleanup() @@ -313,11 +301,13 @@ 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`), - isError: computed(() => status() === `error`), + isLoadingError: computed(() => status() === `error`), + isCrashed: computed(() => status() === `crashed`), isCleanedUp: computed(() => status() === `cleaned-up`), + isSyncError: computed( + () => status() === `ready` && health()?.hasErrors === true, + ), + health, } } diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 5d13b707d4..44c9dfef55 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -38,6 +38,8 @@ import { import { injectLiveQuery } from '../src/index' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-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 { @@ -116,27 +118,12 @@ function makePrecreated( 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() { @@ -153,12 +140,12 @@ 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(), - 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`, + health: result.health(), }) }, flush: settle, @@ -240,6 +227,12 @@ const angularDriver: LiveQueryDriver = { createCollection, `conformance-angular-lagging-${sourceSeq++}`, ), + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( + createCollection, + `conformance-angular-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, @@ -383,11 +376,11 @@ it(`preserves raw result types through the actual driver reader`, () => { state: new Map(), status: `disabled`, isReady: false, - persistedStatus: `unavailable`, - isPersistedReady: false, - persistedError: undefined, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, + health: undefined, } const result = Object.fromEntries( Object.keys(raw).map((key) => [key, () => raw[key]]), @@ -398,9 +391,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/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 3b3b5f4272..14a9fc19ad 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 @@ -5015,7 +5015,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 @@ -5092,7 +5093,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/db-sqlite-persistence-core/README.md b/packages/db-sqlite-persistence-core/README.md index 6205a7da6d..f877c11746 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.health`; 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 69b1b3f2b4..2eaf2a2505 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, @@ -11,7 +10,6 @@ import { getLoadSubsetDemandKey, safeRandomUUID, toBooleanPredicate, - waitForPersistedInitialRender, whenSyncAccepted, withAcceptedReceipt, withCollectionConfigFactory, @@ -52,8 +50,6 @@ import type { LoadSubsetFn, LoadSubsetOptions, PendingMutation, - PersistedReadinessSnapshot, - PersistedReadinessSource, SyncAppliedReceipt, SyncConfig, SyncConfigRes, @@ -798,36 +794,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) {} - waitForInitialRender = waitForPersistedInitialRender - 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 } @@ -965,7 +958,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 } /** @@ -1588,7 +1581,7 @@ class PersistedCollectionRuntime< commit: null, truncate: null, metadata: null, - markError: null, + fail: null, } private startupMetadataPromise: Promise | null = null private startPromise: Promise | null = null @@ -1902,15 +1895,29 @@ class PersistedCollectionRuntime< this.restartSourceAfterScopedRecovery = undefined } - reportSyncError(error: unknown): unknown { - const markError = this.syncControls.markError + /** + * Reports one failure for the current sync run. The wrapped sync adapter's + * 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 fail = this.syncControls.fail if (this.syncErrorReported) return this.reportedSyncError - if (!markError) return error + if (!fail) return error - this.syncErrorReported = true - this.reportedSyncError = error + // 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 { - markError(error) + if (origin === `sync adapter`) fail(error) + else this.collection?._lifecycle.crash(error) } catch { // Reporting must not replace the original asynchronous failure. } @@ -1988,7 +1995,7 @@ class PersistedCollectionRuntime< commit: null, truncate: null, metadata: null, - markError: null, + fail: null, } } @@ -5723,6 +5730,7 @@ function createWrappedSyncConfig< getMap(openTransaction).set(key, write) } let fullStartPromise: Promise | null = null + let stopNetworkFirstGate = () => {} let sourceResultPromise: Promise | null = null let resolveSourceResultAssigned!: () => void const sourceResultAssigned = new Promise((resolve) => { @@ -5773,17 +5781,31 @@ 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`) { - runtime.reportSyncError(error) + 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()) { - runtime.reportSyncError(error) + openFromDisk() + runtime.reportSyncError(error, `sync adapter`) } }, () => undefined, @@ -5859,7 +5881,7 @@ function createWrappedSyncConfig< commit: params.commit, truncate: params.truncate, metadata: params.metadata ?? null, - markError: params.markError, + fail: params.fail, }, ) @@ -5919,26 +5941,48 @@ function createWrappedSyncConfig< } let sourceSession = 0 - const wrappedParams = { - ...params, - markError: reportUpstreamError, - markReady: () => { - if (startupState.cleanedUp || getTerminalFailure()) return - void (fullStartPromise ?? runtime.ensureStarted()) - .then(async () => { + // Upstream reports wait for persisted startup, as markReady does. + // 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 + adapterCalls = adapterCalls + .then(() => fullStartPromise ?? runtime.ensureStarted()) + .then( + async () => { if (isCleanedUp() || getTerminalFailure()) return - await sourceResultAssigned try { - await sourceResultPromise + await call() } catch (error) { - reportUpstreamError(error) - return + // 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) } - if (isCleanedUp() || getTerminalFailure()) return - params.markReady() - }) - .catch(() => undefined) - }, + }, + // A failed startup reports its own failure. + () => undefined, + ) + } + const wrappedParams = { + ...params, + fail: reportUpstreamError, + reportError: (error: unknown) => + afterStartup(() => params.reportError(error)), + reportHealthy: () => afterStartup(params.reportHealthy), + 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() @@ -6294,8 +6338,14 @@ function createWrappedSyncConfig< markReady: () => { if (active()) wrappedParams.markReady() }, - markError: (error?: unknown) => { - if (active()) wrappedParams.markError(error) + 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() @@ -6463,6 +6513,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 @@ -6504,6 +6568,7 @@ function createWrappedSyncConfig< for (const acquisition of acquisitions.values()) { acquisition.cancelAdmissionWait?.() } + stopNetworkFirstGate() acquisitions.clear() pendingPublicationTransactions.length = 0 for (const transaction of transactionStack) { @@ -6694,7 +6759,7 @@ function createLoopbackSyncConfig< commit: params.commit, truncate: params.truncate, metadata: params.metadata ?? null, - markError: params.markError, + fail: params.fail, }, ) @@ -6872,9 +6937,6 @@ export function persistedCollectionOptions< persistedReadinessTracker, ), persistence, - ...(persistedReadinessTracker - ? { [PERSISTED_READINESS]: persistedReadinessTracker } - : {}), } return withCollectionConfigFactory( @@ -6990,9 +7052,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-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 647a9520e4..efa3a6ca3f 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -2731,8 +2731,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, @@ -2885,7 +2885,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`, @@ -3020,7 +3020,7 @@ function expectAbortGraphObservation( predecessorName: `AbortError`, successorStatus: `rejected`, sameFailure: true, - status: `error`, + status: `crashed`, publicSuccessor: undefined, durableSuccessor: undefined, pendingMarkers: 0, @@ -3072,7 +3072,7 @@ function expectOpenTransactionBoundaryObservation( ? `cleaned-up` : boundary === `abort` ? `ready` - : `error`, + : `crashed`, pendingMarkers: 0, }) } @@ -4229,7 +4229,7 @@ describeDurabilityOracles(`generated persistence durability oracles`, () => { predecessorName: `AbortError`, successorStatus: `rejected`, sameFailure: true, - status: `error`, + status: `crashed`, publicSuccessor: undefined, durableSuccessor: undefined, pendingMarkers: 0, @@ -4251,7 +4251,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`, ), }, @@ -4423,7 +4423,7 @@ async function createTerminalFailureHarness( ).toBe(adapterError) } - expect(collection.status).toBe(`error`) + expect(collection.status).toBe(`crashed`) expect(collection._lifecycle.getSyncError()).toBe(terminalError) return { @@ -6130,83 +6130,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 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])( + `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 markError = vi.spyOn(collection._lifecycle, `markError`) - 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, fail }) => { + markReady() + giveUp = fail + 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: markError.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: `crashed`, + error: unloadError, + unloadThenCalls: 1, + unloadOptions: [failing, sibling], + lifecycleErrors: [unloadError], + unhandled: [], + }) - await flushAsyncWork() - expect(sourceUnloadMock).toHaveBeenCalledTimes(2) - expect(markError).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() @@ -6990,7 +7008,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { receiptErrorCode: `SQLITE_FULL`, receiptErrorPath: `adapter.applyCommittedTx`, publicErrorIsReceipt: true, - status: `error`, + status: `crashed`, visible: { id: `failed`, title: `Not durable` }, durable: undefined, }) @@ -7079,7 +7097,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) @@ -7142,7 +7160,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() @@ -7200,29 +7218,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({ @@ -7367,7 +7383,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { namedDurability: true, cause: storageError, exactPublicError: true, - status: `error`, + status: `crashed`, durable: undefined, }) } catch (error) { @@ -7512,7 +7528,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { firstStatus: `rejected`, siblingStatus: `rejected`, sameTerminal: true, - status: `error`, + status: `crashed`, publicErrorIsFirst: true, visibleSibling: undefined, durableSibling: undefined, @@ -7690,7 +7706,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { sameSibling: true, sameLoad: true, namedDurability: true, - status: `error`, + status: `crashed`, publicError: terminalError, publicSibling: undefined, applyCalls: 1, @@ -7833,7 +7849,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { abortedName: `AbortError`, dependentStatus: `rejected`, dependentExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleDependent: undefined, durableDependent: undefined, @@ -9838,7 +9854,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { status: collection.status, exactPublicError: collection._lifecycle.getSyncError() === receiptError, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -9922,7 +9938,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { status: collection.status, exactPublicError: collection._lifecycle.getSyncError() === receiptError, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10025,7 +10041,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }).toEqual({ transactionStatus: `rejected`, transactionExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10412,7 +10428,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { errorName: `PersistedCollectionDurabilityError`, cause: adapterError, publicErrorIsReceipt: true, - status: `error`, + status: `crashed`, firstVisible: { id: `first`, title: `published before persistence`, @@ -10449,7 +10465,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { receiptStatus: `rejected`, exactReceipt: true, publicErrorIsTerminal: true, - status: `error`, + status: `crashed`, lateVisible: undefined, lateDurable: undefined, applyCalls: 1, @@ -10553,7 +10569,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { collectionMetadata: `collection-metadata-before-failure`, applyCalls: applyCallsBefore, publishCalls: publishCallsBefore, - status: `error`, + status: `crashed`, exactPublicError: true, }) } @@ -10628,7 +10644,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { publishCalls: publishCallsBefore, visibleLate: undefined, durableLate: undefined, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10771,7 +10787,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { secondVisible: undefined, secondDurable: undefined, publishCalls: 0, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -10909,7 +10925,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { forceVisible: undefined, lateStatus: `rejected`, lateErrorName: `CollectionStateError`, - status: `error`, + status: `crashed`, exactPublicError: true, applyCalls: 1, firstVisible: undefined, @@ -10950,7 +10966,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() @@ -11022,7 +11038,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { directExact: true, localStatus: `rejected`, localErrorName: `CollectionStateError`, - status: `error`, + status: `crashed`, exactPublicError: true, applyCalls: 0, directVisible: undefined, @@ -11144,7 +11160,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?.() @@ -12549,7 +12565,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { readyExact: true, lateStatus: `rejected`, lateExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleLate: undefined, durableLate: undefined, @@ -12611,7 +12627,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }).toEqual({ startupStatus: `rejected`, startupExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleRows: [], durableRows: @@ -12722,7 +12738,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { readyExact: true, lateStatus: `rejected`, lateExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleBad: undefined, visibleLate: undefined, @@ -14033,7 +14049,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { sameTerminal: true, durabilityCalls: 1, suffixDurable: undefined, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -14315,7 +14331,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { abortedName: `AbortError`, dependentStatus: `rejected`, dependentExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, publicRow: undefined, durableRow: undefined, @@ -14433,7 +14449,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 @@ -14715,7 +14731,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 @@ -18328,7 +18344,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }), }, }) - const unsubscribeError = collection.on(`status:error`, () => { + const unsubscribeError = collection.on(`status:crashed`, () => { terminal.resolve() }) let hasPrimaryFailure = false @@ -18403,7 +18419,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { loadCalls, }).toEqual({ outcome: `terminal`, - status: `error`, + status: `crashed`, publicError: receiptError, loadCalls: 0, }) @@ -18483,7 +18499,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }), }, }) - const unsubscribeError = collection.on(`status:error`, () => { + const unsubscribeError = collection.on(`status:crashed`, () => { terminal.resolve() }) let hasPrimaryFailure = false @@ -18557,7 +18573,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { loadCalls, }).toEqual({ outcome: `terminal`, - status: `error`, + status: `crashed`, publicError: loadError, loadCalls: 1, }) @@ -19539,7 +19555,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() @@ -19900,7 +19916,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { durableRow: adapter.rows.get(`shared`), durabilityCalls: adapter.applyCommittedTxCalls.length, }).toEqual({ - status: `error`, + status: `crashed`, exactPublicError: true, publicRow: undefined, durableRow: { @@ -20967,7 +20983,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) @@ -21054,7 +21070,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 = () => { @@ -21155,7 +21171,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!() @@ -21186,7 +21202,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { receiptStatus: `rejected`, exactReceipt: true, publicErrorIsHydration: true, - status: `error`, + status: `crashed`, lateVisible: undefined, lateDurable: undefined, applyCalls: 0, @@ -22132,7 +22148,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) @@ -22225,11 +22241,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 @@ -22237,7 +22251,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { reentrantFailure = Promise.resolve( collection._sync.loadSubset(sibling), ).catch((reason: unknown) => reason) - markError(error) + crash(error) errorReported.resolve() }) let hasPrimaryFailure = false @@ -22263,7 +22277,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, @@ -22736,7 +22750,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({ @@ -22821,26 +22835,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`, @@ -23557,7 +23569,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 }, @@ -23599,6 +23616,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`, @@ -23783,6 +23802,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.health.failureReason).toBeNull() + sourceSessions[1]!.reportError(new Error(`live session`)) + await flushAsyncWork() + oldSource.reportHealthy() + await flushAsyncWork() + expect(collection.health.failureReason?.message).toBe(`live session`) } finally { releaseOldCallback.resolve() await Promise.allSettled([lateCallback]) @@ -23942,7 +23972,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) } @@ -25942,7 +25972,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`) @@ -26128,7 +26158,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`, @@ -26139,7 +26169,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { : { outcome: `rejected`, rows: undefined, - status: `error`, + status: `crashed`, releases: 1, }, } @@ -26460,7 +26490,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 @@ -26472,9 +26502,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 { @@ -26559,7 +26589,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, @@ -26687,7 +26717,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, @@ -26718,7 +26748,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { : (`rejected` as const), } : {}), - status: failed ? `error` : `healthy`, + status: failed ? `crashed` : `healthy`, } } @@ -26912,7 +26942,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 () => { @@ -27590,12 +27620,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() @@ -27708,14 +27738,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 { @@ -27995,6 +28025,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()), } @@ -28050,6 +28083,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), ), ).toEqual([]) + // Recovery succeeded: the Collection still accepts sync data. + expect(collection.status).toBe(`ready`) source.begin() source.write({ type: `insert`, @@ -28707,7 +28742,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { queuedExact: true, lateStatus: `rejected`, lateExact: true, - status: `error`, + status: `crashed`, exactPublicError: true, visibleSeed: undefined, durableSeed: seed, @@ -28861,7 +28896,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { reloadVisible: undefined, targetedDurable: undefined, reloadDurable: undefined, - status: `error`, + status: `crashed`, exactPublicError: true, }) } catch (error) { @@ -30104,7 +30139,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) @@ -31323,6 +31358,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` } @@ -31721,7 +31759,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 19157c9c2e..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 @@ -1,39 +1,183 @@ -import { describe, expect, it, vi } from 'vitest' -import { - DbClient, - PERSISTED_READINESS, - createCollection, - createLiveQueryCollection, - createLiveQueryObserver, - eq, - getPersistedReadinessSource, - waitForPersistedInitialRender, -} 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` | `ready-report` | `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` +const REMOTE_REPORT = `remote retrying` + +// --------------------------------------------------------------------------- +// Independent model +// --------------------------------------------------------------------------- + +type Expected = { + status: `loading` | `ready` | `error` | `crashed` + error: string | null + failureReason: 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 + let failureReason: 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, + // 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() + 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(false) + else if (waitingRemote === `ready-report`) applyRemoteReady(true) + 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 === `fail`) applyRemoteFailure() + else applyRemoteReady(event.outcome === `ready-report`) + } + } + return { + status, + error, + failureReason: status === `crashed` ? null : failureReason, + 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`, `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 }) + if (deadline) events.push({ kind: `deadline` }) + histories.push(...permutations(events)) + } } } +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- + function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void @@ -44,20 +188,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, @@ -72,1229 +213,206 @@ 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: (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)), } +} - 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.health.error?.message ?? null, + failureReason: collection.health.failureReason?.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: () => () => {}, - waitForInitialRender: waitForPersistedInitialRender, - }, - }) - } - if (rightOptedIn) { - Object.defineProperty(right.config, PERSISTED_READINESS, { - value: { - networkTimeoutMs: 0, - getOrStartNetworkDeadline: () => Date.now(), - getSnapshot: () => - rightOutcome === `error` - ? { status: `error`, error: rightError } - : { status: rightOutcome }, - subscribe: () => () => {}, - waitForInitialRender: waitForPersistedInitialRender, - }, - }) - } - 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 === `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]) + // 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(`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(`rejects network-first initial rendering for on-demand persistence`, () => { - const fixture = recordingAdapter([]) - expect(() => - persistedCollectionOptions({ - id: `local-on-demand`, - getKey: (row) => row.id, - syncMode: `on-demand`, - 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([]) - expect(() => - persistedCollectionOptions({ - id: `invalid-network-deadline`, - getKey: (row) => row.id, - persistence: { adapter: fixture.adapter }, - initialRender: { strategy: `network-first`, networkTimeoutMs }, - }), - ).toThrow(/networkTimeoutMs/) }, ) - it(`settles a local-only persisted Collection after its eager restore`, async () => { - const fixture = recordingAdapter([{ id: `one`, value: `local` }]) - const persisted = createCollection( + // 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: `local-only-ready`, + id: `contract-violation`, getKey: (row) => row.id, persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 0, + sync: { + sync: (params) => { + params.reportError(new Error(`reported before ready`)) + }, }, }), ) - 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 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.health.error?.message).toMatch(/reportError/) + } finally { + consoleError.mockRestore() + await collection.cleanup({ dependents: true }) } - 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) + 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(false) + await vi.advanceTimersByTimeAsync(0) + expect(source.collection.status).toBe(`ready`) + } finally { + await source.collection.cleanup({ dependents: true }) } - 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( + it(`defaults the network-first deadline to three seconds`, async () => { + const fixture = controlledAdapter() + const collection = createCollection( persistedCollectionOptions({ - id: `network-first-success`, + id: `default-network-deadline`, getKey: (row) => row.id, persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 1_000, - }, - sync: { - sync: ({ markReady }) => { - markCollectionReady = markReady - return {} - }, - }, + initialRender: { strategy: `network-first` }, + sync: { sync: () => {} }, }), ) - 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()]) + 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(`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( + it(`rejects network-first initial rendering for on-demand persistence`, () => { + const fixture = controlledAdapter() + expect(() => persistedCollectionOptions({ - id: `network-first-failure`, + id: `local-on-demand`, getKey: (row) => row.id, + syncMode: `on-demand`, + initialRender: { strategy: `network-first`, networkTimeoutMs: 100 }, persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 1_000, - }, - sync: { - sync: ({ markError }) => { - markNetworkError = markError - 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(), - ]) + ).toThrow(/network-first.*eager/i) }) - for (const failureMode of [`markError`, `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( + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])( + `rejects an invalid network timeout (%s)`, + (networkTimeoutMs) => { + const fixture = controlledAdapter() + expect(() => persistedCollectionOptions({ - id: `network-failure-before-restore`, + id: `invalid-network-deadline`, getKey: (row) => row.id, persistence: { adapter: fixture.adapter }, - initialRender: { - strategy: `network-first`, - networkTimeoutMs: 1_000, - }, - sync: { - sync: ({ markError }) => { - networkSyncStarted = true - if (failureMode === `throw`) throw networkFailure - markNetworkError = markError - return {} - }, - }, + initialRender: { strategy: `network-first`, networkTimeoutMs }, }), - ) - 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 === `markError`) 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: () => () => {}, - waitForInitialRender: waitForPersistedInitialRender, - }, - }) - 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: () => () => {}, - waitForInitialRender: waitForPersistedInitialRender, - }, - }) - 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(), - ]) - }) + ).toThrow(/networkTimeoutMs/) + }, + ) +}) - 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-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts index f48706c9e0..8605d3b5f4 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 @@ -3558,7 +3558,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 }, @@ -3603,6 +3608,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/e2e/indexed-db/browser.ts b/packages/db/e2e/indexed-db/browser.ts index e5e1d07c7d..fea6df599f 100644 --- a/packages/db/e2e/indexed-db/browser.ts +++ b/packages/db/e2e/indexed-db/browser.ts @@ -263,6 +263,7 @@ async function setup() { tab, rows: snapshot(collection.values()), status: collection.status, + syncError: collection.health.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 3993d3ba38..ac09a5230e 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 stops syncing: the Collection's `error`, with its rows and + // status kept. + expect(state.status).toBe('ready') + if (obsolete) expect(state.syncError).toBeNull() + else expect(state.syncError).toEqual(expect.any(String)) if (obsolete) assertStatuses( state.statuses, @@ -966,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) @@ -991,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) @@ -1028,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) @@ -1061,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') @@ -1093,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') @@ -1114,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') }) }) @@ -1178,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'] }) }) }) } diff --git a/packages/db/mangle-cache.json b/packages/db/mangle-cache.json index acc9210bbf..12118f9366 100644 --- a/packages/db/mangle-cache.json +++ b/packages/db/mangle-cache.json @@ -47,7 +47,6 @@ "truncateReplacementPending": "aR", "lastLiveQueryTimestamp": "aS", "pendingChanges": "aT", - "readPersistedReadiness": "aU", "statusRevision": "aV", "enrichWithVirtualPropsSnapshot": "aW", "lease": "aX", @@ -59,7 +58,6 @@ "_events": "a3", "isDemandActive": "a4", "loadSubsetOperations": "a5", - "cancelInitialRenderWait": "a6", "pendingSyncedProjection": "a7", "hasHydrationError": "a9", "orderedLoadFailed": "ba", @@ -82,7 +80,6 @@ "appliedLimit": "bt", "cachedCollectionLayoutRevision": "bu", "collectionsById": "bv", - "initialRenderPromise": "bw", "maybeRunGraphFn": "bx", "preloadedLiveQueries": "bz", "rebuildPendingSyncedProjection": "bA", @@ -93,7 +90,6 @@ "updateCachedEntries": "bF", "drainingNoInputContinuations": "bG", "retiredEntries": "bH", - "getComputedHasNextPage": "bI", "checkTruncateReplayComplete": "bJ", "liveQueryResources": "bK", "markLiveResultAuthoritative": "bL", @@ -116,15 +112,12 @@ "sentToD2RowsBySource": "b3", "dispatching": "b4", "cleanupPromise": "b5", - "isInErrorState": "b6", "orderedLoaders": "b7", "retainedWindow": "b8", "setReadyIfIdle": "b9", "cachedCollectionRevision": "ca", "collectionRestartCleanup": "cb", - "erroredSourceIds": "cc", "pendingHydration": "cd", - "persistedSources": "ce", "removeFromBucket": "cf", "restorePublishedSnapshotTracking": "cg", "scheduleHydrationHandoff": "ch", @@ -151,8 +144,8 @@ "canCompleteInitialLoad": "cD", "registerClientResource": "cE", "createSubsetAcquisitionRecord": "cF", + "cancelRepairRetry": "cG", "getVisibleEntries": "cH", - "transitionToError": "cI", "baselineWindow": "cJ", "enrichChangeWithVirtualProps": "cK", "hydrationError": "cL", @@ -188,11 +181,9 @@ "clearOriginTrackingState": "dh", "handleSourceCleanupStart": "di", "handleSourceStatusChange": "dj", - "initialRenderReady": "dk", "lastBoundary": "dl", "microtaskScheduled": "dm", "resolveValue": "dn", - "syncError": "dp", "windowFailed": "dq", "cleaningUp": "dr", "resolvedValues": "ds", @@ -272,7 +263,6 @@ "compiledIndexEvaluator": "eK", "nextLiveQueryTimestamp": "eL", "restartDetachedDemands": "eM", - "trackAttachmentFailure": "eN", "warnedPlans": "eO", "abandonTruncateReplay": "eP", "captureEntries": "eQ", @@ -294,7 +284,6 @@ "publishEvents": "e5", "releaseDemand": "e6", "rollbackLease": "e7", - "setErrorState": "e8", "trackSentKeys": "e9", "updateTimeout": "fa", "flushTruncateReplay": "fb", @@ -394,26 +383,63 @@ "copyRows": "gD", "attach": "gF", "sortedExactValues": "gG", - "retiringSupersededAcquisitions": "l", - "needsOptimisticRefill": "F", - "graphCheckedDuringRequest": "W", - "retireSupersededFailures": "gH", - "ownPrefixCount": "gI", - "graphHasProcessedInput": "gJ", - "repairNeedsReplacement": "gK", - "coversSource": "gL", - "standing": "gM", - "windowIsInsideOwnPrefix": "gN", - "debt": "gO", - "releaseOwned": "gP", - "moveWork": "ae", - "checkCoherence": "gQ", - "supersedes": "gR", - "oweDebt": "gS", - "isDeferredSyncKey": "bf", - "overlayHeldAndActive": "c7", - "cancelRepairRetry": "cG", - "coverSource": "fN", - "windowRequestGeneration": "av", - "latestWindowRequest": "gE" + "reachedReady": "cc", + "requestErrors": "gH", + "reportFailedRequest": "gJ", + "includeSourceIds": "gL", + "latchReady": "gO", + "reportedTree": "gQ", + "endedProblem": "gS", + "assertAdapterActive": "bI", + "statusError": "cI", + "stoppedError": "eN", + "statusAllowsError": "gI", + "enterErrorState": "gM", + "crashQuery": "gN", + "failQuery": "gW", + "assertReady": "gX", + "reloadRun": "gZ", + "adapterStopped": "g_", + "recovering": "g$", + "abortLoadSubsetWork": "g0", + "markReloadTruncate": "g1", + "commitEmptyReloadTruncate": "g2", + "dependentEntry": "g3", + "enterSync": "g4", + "retiringSupersededAcquisitions": "aU", + "needsOptimisticRefill": "a6", + "graphCheckedDuringRequest": "bw", + "ownPrefixCount": "ce", + "graphHasProcessedInput": "dk", + "windowIsInsideOwnPrefix": "gE", + "failureReasonFromReload": "g5", + "endReload": "g9", + "loadOrder": "ha", + "cancelSyncTransaction": "g7", + "startedBeforeFailure": "hb", + "views": "he", + "isDeferredSyncKey": "b6", + "overlayHeldAndActive": "e8", + "recordOutcome": "gP", + "replacements": "dp", + "onLoaded": "gR", + "publishedHealth": "gK", + "reportHealth": "gT", + "healthTree": "gV", + "emittingReloadTruncate": "gU", + "reloadReplayed": "gY", + "replaysDemand": "g6", + "latestWindowRequest": "l", + "coversSource": "F", + "moveWork": "W", + "windowRequestGeneration": "ae", + "retireSupersededFailures": "av", + "repairNeedsReplacement": "bf", + "releaseOwned": "c7", + "standing": "fN", + "debt": "g8", + "checkCoherence": "hc", + "coverSource": "hd", + "supersedes": "hf", + "oweDebt": "hg" } 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..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 in `collection.utils.lastError`. 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 022485e31b..f7d8204738 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, fail, + reportError, reportHealthy, truncate, metadata primitives and returning cleanup, loadSubset, and optional unloadSubset handlers. ChangeMessage format (insert, update, delete). On-demand LoadSubsetOptions @@ -54,7 +55,7 @@ function myBackendCollectionOptions(config: { return { getKey: config.getKey, sync: { - sync: ({ begin, write, commit, markReady, markError, collection }) => { + sync: ({ begin, write, commit, markReady, fail }) => { let isInitialSyncComplete = false const bufferedEvents: Array> = [] const initialSyncAbort = new AbortController() @@ -94,9 +95,10 @@ 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. - if (collection.status === 'loading') markError(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 @@ -233,7 +235,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) @@ -277,7 +279,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() } ``` @@ -346,13 +348,23 @@ 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 `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; 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.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. Source: docs/guides/collection-options-creator.md 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/events.ts b/packages/db/src/collection/events.ts index 92bf0c1b67..c31b702701 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 health tree may have changed: its own + * status or problems, or for a live query, those of a Collection it depends + * on. Read `collection.health` for the current tree. + */ +export interface CollectionHealthChangeEvent { + type: `health:change` + collection: Collection +} + /** * Event emitted when the collection is truncated (all data cleared) */ @@ -111,6 +121,7 @@ export type AllCollectionEvents = { 'status:change': CollectionStatusChangeEvent 'subscribers:change': CollectionSubscribersChangeEvent 'loadingSubset:change': CollectionLoadingSubsetChangeEvent + 'health:change': CollectionHealthChangeEvent truncate: CollectionTruncateEvent 'loadedSubsets:invalidated': CollectionLoadedSubsetsInvalidatedEvent 'index:added': CollectionIndexAddedEvent @@ -176,6 +187,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/health-tree.ts b/packages/db/src/collection/health-tree.ts new file mode 100644 index 0000000000..8c3c4bc13d --- /dev/null +++ b/packages/db/src/collection/health-tree.ts @@ -0,0 +1,108 @@ +import { getCollectionBuilder } from '../query/live/collection-registry.js' +import type { HealthNode } from '../types' +import type { CollectionImpl } from './index.js' + +/** A Collection that another Collection reads, under its query alias. */ +export interface HealthSource { + readonly alias: string + /** Tells apart two sources under one alias, such as two includes. */ + readonly id?: string + readonly collection: CollectionImpl + 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>() + +/** + * 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 buildHealthTree( + collection: CollectionImpl, + { alias, id, kind }: 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, +): HealthNode { + // A live query's tree shape comes from its query definition. + const builder = getCollectionBuilder(collection) + const sources = builder?.healthSources() ?? [] + const { failureReason, error } = collection._lifecycle.problems + const children = sources.map((source) => + buildHealthTree(source.collection, source, collection), + ) + return healthNode(owner, id, { + alias, + collectionId: collection.id, + kind: kind ?? (builder ? `live-query` : `source`), + status: collection.status, + failureReason, + error, + reloading: collection._lifecycle.reloading, + sources: children, + }) +} + +/** + * 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 healthNode( + owner: object, + id: string | undefined, + fields: Omit, +): HealthNode { + const { failureReason, error, sources } = fields + return cachedNode(owner, id ?? fields.alias, { + ...fields, + hasErrors: + failureReason !== null || + error !== null || + sources.some((child) => child.hasErrors), + }) +} + +/** + * 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(health: HealthNode): Array { + 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: HealthNode): HealthNode { + let nodes = nodeCache.get(owner) + if (!nodes) nodeCache.set(owner, (nodes = new Map())) + const previous = nodes.get(key) + if (previous && sameNode(previous, next)) return previous + nodes.set(key, next) + return next +} + +function sameNode(a: HealthNode, b: HealthNode): boolean { + return ( + a.collectionId === b.collectionId && + a.kind === b.kind && + a.status === b.status && + a.hasErrors === b.hasErrors && + a.failureReason === b.failureReason && + a.error === b.error && + a.reloading === b.reloading && + a.sources.length === b.sources.length && + a.sources.every((source, index) => source === b.sources[index]) + ) +} diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index ba6853ff00..890f845c3f 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' @@ -17,6 +19,7 @@ import { CollectionSyncManager } from './sync' import { CollectionIndexesManager } from './indexes' import { CollectionMutationsManager } from './mutations' import { CollectionEventsManager } from './events.js' +import { buildHealthTree } from './health-tree.js' import type { SortedMap } from '../SortedMap.js' import type { PublicationDeferral } from './changes' import type { CollectionSubscription } from './subscription' @@ -30,8 +33,10 @@ import type { IndexOptions } from '../indexes/index-options.js' import type { ChangeMessage, CollectionConfig, + CollectionDependent, CollectionStatus, CurrentStateAsChangesOptions, + HealthNode, InferSchemaInput, InferSchemaOutput, InsertConfig, @@ -50,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 './health-tree.js' export type CollectionBase = Pick< SortedMap, @@ -554,6 +560,21 @@ export class CollectionImpl< return this._lifecycle.status } + /** + * 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 health(): HealthNode { + return buildHealthTree(this) + } + + /** @internal A Collection this one depends on changed its health tree. */ + public _emitHealthChange(): void { + this._events.emitHealthChange() + } + /** * 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. @@ -1275,6 +1296,47 @@ 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 + * 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. + */ + public reload(): Promise { + const builder = getBuilderFromConfig(this.config) + return builder ? builder.reload(this) : 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 @@ -1282,9 +1344,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/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..e03f9a81be 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,9 +12,14 @@ 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 { CollectionConfig, CollectionStatus } from '../types' +import type { + CollectionConfig, + CollectionErrorInfo, + CollectionStatus, +} from '../types' import type { CollectionEventsManager } from './events' import type { CollectionIndexesManager } from './indexes' import type { CollectionChangesManager } from './changes' @@ -51,7 +57,25 @@ export class CollectionLifecycleManager< private readyFailureSink: ((failure: { error: unknown }) => void) | undefined public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null - private syncError: unknown + // 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. + 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. + 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 @@ -100,14 +124,23 @@ export class CollectionLifecycleManager< CollectionStatus, Array > = { - idle: [`loading`, `error`, `cleaned-up`], - loading: [`ready`, `error`, `cleaned-up`], - ready: [`cleaned-up`, `error`], - error: [`ready`, `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 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`). + error: [`cleaned-up`, `idle`, `crashed`], + // Nothing recovers a crash in place; only cleanup leaves it. + crashed: [`cleaned-up`], + 'cleaned-up': [`loading`, `error`, `crashed`], } - if (!validTransitions[from].includes(to)) { + if ( + !validTransitions[from].includes(to) && + !(this.recovering && from === `error` && to === `ready`) + ) { throw new InvalidCollectionStatusTransitionError(from, to, this.id) } } @@ -138,11 +171,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 health tree. status:change fires first, and + // the tree is already current when either event fires. + if (isCurrent()) this.events.emitHealthChange() } /** @@ -152,6 +185,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 @@ -170,6 +204,18 @@ 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.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) @@ -196,9 +242,9 @@ export class CollectionLifecycleManager< beforeEffects?: () => void, ): { error: unknown } | undefined { this.validateStatusTransition(this.status, `ready`) - // A successful initial sync or recovery establishes a ready snapshot. + // A successful initial sync establishes a ready snapshot, as does a + // reload of a failed first load. if (this.status === `loading` || this.status === `error`) { - this.syncError = undefined const readyRevision = this.statusRevision + 1 this.setStatus(`ready`, true, beforeEffects) @@ -235,16 +281,218 @@ export class CollectionLifecycleManager< return undefined } - /** Mark an asynchronous sync failure after sync has started. */ - public markError(error?: unknown): void { - this.validateStatusTransition(this.status, `error`) - this.syncError = error - this.setStatus(`error`) + /** + * 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 fail(error: unknown): void { + if (this.status === `crashed`) return + this.assertAdapterActive(`fail`) + 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 + this.stoppedError = describeProblem(error) + 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.emitHealthChange() + } else { + this.validateStatusTransition(this.status, `error`) + this.statusError = describeProblem(error) + this.setStatus(`error`) + } + reload?.reject(error) + } + + /** Whether the current sync run's adapter called `fail()`. */ + public get stoppedThisRun(): boolean { + 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 + } + + /** + * 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.failureReasonFromReload = false + this.adapterStopped = false + this.events.emitHealthChange() + return run.promise + } + + /** + * The reload's first commit replaced the rows: its problems clear, and a + * failed first load becomes ready. + */ + public completeReload(beforeEffects?: () => void): void { + const run = this.reloadRun + if (!run) return + this.reloadRun = undefined + if (!this.failureReasonFromReload) this.failureReason = null + this.stoppedError = null + this.requestErrors = new RequestErrors() + 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. + this.statusError = null + this.recovering = true + try { + this.markReady(beforeEffects) + } 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 + } + } + run.resolve() + } + + /** 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 { + if (!this.endReload(error)) return + if (this.status !== `cleaned-up`) this.events.emitHealthChange() + } + + private endReload(error: unknown): boolean { + const run = this.reloadRun + if (!run) return false + this.reloadRun = undefined + this.sync.dropReloadReplacement() + run.reject(error) + return true + } + + /** + * Local storage or an internal invariant broke. The Collection can no + * 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 + this.validateStatusTransition(this.status, `crashed`) + // 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`) + } + + /** The Collection's own problems, as health 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 } } - /** Return the cause supplied by the current sync run, if any. */ + /** The cause of status `error` or `crashed`, as health tree detail. */ + public get statusProblem(): CollectionErrorInfo | null { + return this.statusAllowsError() ? this.statusError : null + } + + private statusAllowsError(): boolean { + return this.status === `error` || this.status === `crashed` + } + + /** 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.failureReasonFromReload = this.reloading + this.events.emitHealthChange() + } + + /** The source synced successfully again. With nothing to clear it does nothing. */ + public reportHealthy(): void { + if (this.status === `crashed`) return + this.assertAdapterActive(`reportHealthy`) + this.assertReady(`reportHealthy`) + if (!this.failureReason) return + this.failureReason = null + 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.emitHealthChange() + } + + /** @internal The failed request settled or was released. */ + public clearRequestError(key: string): void { + if (this.requestErrors.settle(key)) this.events.emitHealthChange() + } + + /** fail() ends the sync adapter's reporting for its sync run. */ + private assertAdapterActive(operation: string): void { + 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.` + : codedMessage(247, { operation, id: this.id }), + ) + } + } + + private assertReady(operation: string): void { + if (this.status !== `ready`) { + throw new CollectionStateError( + devBuild() && process.env.NODE_ENV !== `production` + ? `${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 of status `error` or `crashed` in this sync run, if any. */ public getSyncError(): unknown { - return this.syncError + return this.statusError?.cause } public assertCanStartSync(): void { @@ -419,6 +667,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. @@ -479,7 +728,24 @@ export class CollectionLifecycleManager< CleanupQueue.getInstance().cancel(this) this.hasBeenReady = false - this.syncError = undefined + // 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() + // An abandoned reload announces the tree; otherwise announce dropped + // problems here, since the status changes only when cleanup settles. + this.abandonReload(new CollectionPreloadAbortedError()) + const after = this.problems + if ( + !reloading && + (before.error !== after.error || + before.failureReason !== after.failureReason) + ) + this.events.emitHealthChange() // Cleanup is not readiness. Sync cleanup rejects pending preload callers; // first-ready listeners belong to the discarded run. this.onFirstReadyCallbacks = [] @@ -521,3 +787,71 @@ export class CollectionLifecycleManager< return this.beginCleanup() } } + +/** @internal Requests that failed after initial-query readiness, by issuer. */ +export class RequestErrors { + // 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.delete(key) + // 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) + } + + /** Returns whether the shown problem changed. */ + settle(key: string): boolean { + 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 + } +} + +/** + * @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 + // 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.startedBeforeFailure) settle() + }, + () => { + this.startedBeforeFailure = this.started + }, + ) + } +} + +/** @internal Health tree detail for a problem value. */ +export function describeProblem(error: unknown): CollectionErrorInfo { + const info = { + 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/state.ts b/packages/db/src/collection/state.ts index 05f8d526ef..c472563e3b 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -58,6 +58,12 @@ interface PendingSyncedTransaction< layoutChanged: boolean operations: Array> truncate?: boolean + // 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 /** The last explicit write per key, after `position` operations. */ @@ -115,6 +121,15 @@ 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 + // 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 > = [] @@ -907,6 +922,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 @@ -919,8 +944,16 @@ 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. + if (this.reloadTruncatePending) return const { states, truncated } = this.pendingSyncedProjection if (!truncated) { for (const entry of this.syncedData.entries()) { @@ -1263,10 +1296,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.reloadReplacement === true + 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 @@ -1472,8 +1510,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 9027af7a4f..9f3f81a9ac 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,11 +88,19 @@ 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<{ 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 + // 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 = { @@ -113,6 +122,12 @@ type SubsetDemand = { acquisition: SubsetAcquisitionRecord acquisitionState: `starting` | `active` | `detached` initialResult?: Deferred + // 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 } type TruncateReplayAttempt = { @@ -120,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 | undefined - pending: Set<{ demand: SubsetDemand; attempt: TruncateReplayAttempt }> + pending: Set pendingSetups: number currentAttempt: TruncateReplayAttempt failures: Map @@ -197,6 +217,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 private readonly loadSubsetPromiseErrors = new WeakMap< Promise, Error @@ -205,6 +229,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 } @@ -254,6 +283,14 @@ 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 that replays on-demand demand started its replay when it + // 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(() => { @@ -302,14 +339,50 @@ export class CollectionSubscription } } + /** + * @internal A reload retired the sync run. Its demand detaches from the + * 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 { + // 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() + } + + /** 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 + ) + } + /** Detach logical demand from work owned by a discarded sync run. */ - private handleCollectionCleanup(): void { + private handleCollectionCleanup( + keepWaitingCallers = false, + holdReplacement = false, + ): void { this.discardTruncateReplay() + this.truncateReplacementPending = holdReplacement this.stalePublishedRows = new Map(this.publishedRows) this.pendingLoadSubsetParticipants.clear() for (const demand of [...this.subsetDemands]) { - demand.initialResult?.reject(new LoadSubsetOperationAbortedError()) + if (!keepWaitingCallers || demand.acquisitionState === `starting`) { + demand.initialResult?.reject(new LoadSubsetOperationAbortedError()) + } cancelAcquisition(demand.acquisition) if (demand.acquisitionState === `starting`) { const index = this.subsetDemands.indexOf(demand) @@ -338,6 +411,7 @@ export class CollectionSubscription } if ( this.collection.status === `error` || + this.collection.status === `crashed` || (this.collection.config.syncMode === `on-demand` && this.collection._sync.syncLoadSubsetFn === null) ) { @@ -394,15 +468,10 @@ export class CollectionSubscription * authoritative replay succeeds. */ 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 - // 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] : [] + 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.replaysDemand() ? [...this.subsetDemands] : [] // Retained rows still need the committed replacement even without demand. if (demandsToReload.length === 0 && this.stalePublishedRows.size === 0) { @@ -488,11 +557,18 @@ export class CollectionSubscription replayState.failed = true } } - if (demand.initialResult) { - void replayState.completion.promise.then( - demand.initialResult.resolve, - demand.initialResult.reject, - ) + 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. + 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 @@ -561,7 +637,7 @@ export class CollectionSubscription attempt: TruncateReplayAttempt, demand: SubsetDemand, result: LoadSubsetRequestResult, - ): void { + ): TruncateReplayParticipant | undefined { if ( this.truncateReplayState !== replayState || (replayState.currentAttempt !== attempt && @@ -569,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 @@ -596,6 +672,7 @@ export class CollectionSubscription this.settleTruncateReplay(replayState, pending) }, ) + return pending } /** Stop obsolete logical demand from pinning a replay barrier. */ @@ -615,9 +692,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) { @@ -639,7 +724,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) + 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. @@ -817,6 +905,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 } @@ -1032,6 +1125,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. @@ -1041,6 +1138,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))) ) { @@ -1056,12 +1154,17 @@ export class CollectionSubscription demand.initialResult = undefined } void initialResult.promise.then(finish, finish) + if (replayState && replayAttempt) + demand.parkedParticipant = 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. @@ -1305,16 +1408,42 @@ export class CollectionSubscription /** Release one exact subset request while keeping the subscription alive. */ releaseSnapshot(where: BasicExpression): 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, + replaced: Array>, + ): 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 { + return ( + demand.requestOptions.where === where || + this.requestedSubsetWhere.get(demand.requestOptions) === where + ) + } + private releaseDemand( demand: SubsetDemand, primaryFailure?: { error: unknown }, @@ -1372,7 +1501,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) { @@ -1385,6 +1518,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/collection/sync.ts b/packages/db/src/collection/sync.ts index 103c847f55..c6892471ff 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, @@ -91,10 +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. private pendingSyncEntryCleanup: | { tasks: Array> @@ -195,10 +199,52 @@ export class CollectionSyncManager< } const syncCallbackEpoch = ++this.syncCallbackEpoch + this.lifecycle.setStatus(`loading`) + 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(): Promise { + const status = this.lifecycle.status + 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) { + return Promise.reject(new CollectionReloadDuringSyncEntryError(this.id)) + } + 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.crash(error)) + // Subscriptions detach demand from the retired run; the reload's + // truncate replays it through the new run's loader. + this.lifecycle.retireSubscriptionDemand() + this.state.reloadTruncatePending = true + try { + this.enterSync(syncCallbackEpoch) + } catch (error) { + this.lifecycle.abandonReload(error) + } + return reload + } + + private enterSync(syncCallbackEpoch: number): void { const isCurrentSync = () => syncCallbackEpoch === this.syncCallbackEpoch let loadedSubsetsInvalidationQueued = false - this.lifecycle.setStatus(`loading`) - if (!isCurrentSync()) return const finishReadyFailures = this.lifecycle.deferReadyFailures() let readyFailure: { error: unknown } | undefined @@ -225,6 +271,8 @@ export class CollectionSyncManager< applied, duplicateKeyError: (key) => this.createDuplicateKeyError(key), }) + if (this.state.reloadTruncatePending && this.lifecycle.reloading) + this.markReloadTruncate(applied) }, write: ( messageWithOptionalKey: ChangeMessageOrDeleteKeyMessage< @@ -295,12 +343,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, ) @@ -317,16 +365,36 @@ 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.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. + 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) + }, + reportError: (error: unknown) => { + if (isCurrentSync()) this.lifecycle.reportError(error) }, - markError: (error?: unknown) => { - if (isCurrentSync()) this.lifecycle.markError(error) + reportHealthy: () => { + if (isCurrentSync()) this.lifecycle.reportHealthy() }, truncate: (options) => { if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() + // The adapter's own truncate stays even if a reload ends early. + pendingTransaction.adapterTruncate = true // Clear all operations from the current transaction pendingTransaction.operations = [] @@ -403,7 +471,11 @@ 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. + // 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 @@ -469,6 +541,95 @@ 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.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. + const kept = this.state.createSyncedKeyLookup() + open.operations = open.operations.flatMap((operation) => + operation.originalSyncType === `insert` && kept(operation.key as TKey) + ? [ + { ...operation, type: `delete`, originalSyncType: undefined }, + operation, + ] + : [operation], + ) + this.state.refreshPendingSyncedProjection() + } + + /** The reload's first transaction replaces every synced row. */ + private markReloadTruncate(applied: { promise: Promise }): void { + this.state.reloadTruncatePending = false + const transaction = this.state.pendingSyncedTransactions.at(-1)! + transaction.reloadReplacement = true + 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) => { + // 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.reloadReplacement && this.lifecycle.reloading) + this.state.reloadTruncatePending = true + } + + 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) @@ -628,7 +789,10 @@ export class CollectionSyncManager< return } - if (this.lifecycle.status === `error`) { + if ( + this.lifecycle.status === `error` || + this.lifecycle.status === `crashed` + ) { reject(this.getPreloadError()) return } @@ -665,12 +829,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 ( @@ -686,7 +856,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. @@ -986,32 +1159,27 @@ 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()) + this.state.discardOpenSyncTransactions() 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) { @@ -1033,6 +1201,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.state.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 7ced1918a7..68516564f8 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 not recovered in place; clean it up with { dependents: true } and start it again.` + : 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/index.ts b/packages/db/src/index.ts index 00e18d9dda..8b0eb0b3c9 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -16,8 +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 './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/indexed-db.ts b/packages/db/src/indexed-db.ts index 5448ed9b7a..b54e6bf603 100644 --- a/packages/db/src/indexed-db.ts +++ b/packages/db/src/indexed-db.ts @@ -690,15 +690,25 @@ 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 - markError(connection?.error) + // Closure ends read and admission authority, even after ready, so the + // Collection crashes instead of reporting a sync problem. + params.collection._lifecycle.crash(connection?.error) } } if (connection?.error) { @@ -773,16 +783,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 3e8e15a6c8..f4162ea535 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 } from './types.js' +import type { ChangeMessage, CollectionStatus, HealthNode } 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,14 +44,24 @@ 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 - isError: boolean + /** The first load failed: there is nothing to show. */ + isLoadingError: boolean + /** Local storage or an invariant broke. Only a cascade cleanup and a fresh start recover. */ + 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 `health`. + */ + isSyncError: boolean + /** + * 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. + */ + health: HealthNode | undefined } /** @@ -138,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. */ @@ -178,13 +140,13 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot = { status: `disabled`, isLoading: false, isReady: true, - persistedStatus: `unavailable`, - isPersistedReady: false, - persistedError: undefined, isIdle: false, - isError: false, + isLoadingError: false, + isCrashed: false, isCleanedUp: false, isEnabled: false, + isSyncError: false, + health: undefined, } class LiveQueryObserverImpl< @@ -195,14 +157,15 @@ 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 private cachedCollectionRevision: number | undefined private cachedCollectionLayoutRevision: number | undefined private snapshotDirty = true + // 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 publishedHealth: { tree: HealthNode | undefined } | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private layoutRevision = 0 private lastLayoutKeys: Array | undefined @@ -228,9 +191,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 @@ -248,9 +208,6 @@ class LiveQueryObserverImpl< this.wholesale = wholesale this.client = client this.queryHash = queryHash - this.persistedSources = collection - ? collectPersistedReadinessSources(collection) - : undefined this.onPreload = onPreload this.registerClientResource() } @@ -262,18 +219,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) @@ -309,6 +254,9 @@ class LiveQueryObserverImpl< this.layoutRevision++ } + 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 | undefined this.cachedSnapshot = { @@ -320,10 +268,9 @@ class LiveQueryObserverImpl< layoutRevision: this.layoutRevision, status, ...getLiveQueryStatusFlags(status), - persistedStatus, - isPersistedReady: persistedStatus === `ready`, - persistedError: local.error, isEnabled: true, + isSyncError: status === `ready` && health?.hasErrors === true, + health, } this.snapshotDirty = false } @@ -359,29 +306,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]> { @@ -508,8 +432,7 @@ class LiveQueryObserverImpl< !this.attached || !collection || !this.hasHydrationSeed() || - (collection.status !== `ready` && - this.readPersistedReadiness().status !== `ready`) || + collection.status !== `ready` || collection.isLoadingSubset ) { return @@ -751,9 +674,23 @@ 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 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.publishedHealth = { tree: healthOf() } + notify(undefined, status) + }) + 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) + }) const subscribeLayoutChanges = ( collection as Collection & { _subscribeLayoutChanges?: (listener: () => void) => () => void @@ -765,20 +702,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 @@ -807,10 +730,11 @@ class LiveQueryObserverImpl< }) : noop const release = () => { + this.publishedHealth = undefined clientUnsub() statusUnsub() + healthUnsub() layoutUnsub() - persistedUnsubs?.forEach((unsubscribe) => unsubscribe()) subscription?.unsubscribe() } this.collectionUnsub = release @@ -845,12 +769,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() } } @@ -880,7 +799,7 @@ class LiveQueryObserverImpl< async () => { const collection = this.collection this.dispose() - await collection?.cleanup() + await collection?.cleanup({ dependents: true }) }, ) } @@ -1015,79 +934,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 currentClientQuery = () => - this.client && this.queryHash - ? this.client._getLiveQuery(this.queryHash) - : undefined - if (this.collection) INITIAL_RENDER_PRELOADS.add(this.collection) - - // The persistence adapter supplies the network-first wait with its - // readiness, so an app with no persisted source does not ship it. - const { promise: initialRender, cancel } = - sources[0]!.readiness.waitForInitialRender({ - sources, - network, - clientQuery: currentClientQuery(), - currentClientQuery, - collectionStatus: () => this.collection?.status, - readPersisted: () => this.readPersistedReadiness(), - onReady: () => { - this.initialRenderReady = true - }, - }) - this.cancelInitialRenderWait = cancel - const promise = initialRender.finally(() => { - if (this.cancelInitialRenderWait === cancel) { - this.cancelInitialRenderWait = undefined - } - 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 98b5a191cc..a338199510 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -2,21 +2,14 @@ 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' 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 } from './types.js' +import type { CollectionStatus, HealthNode } from './types.js' import type { Context, InitialQueryBuilder, @@ -522,21 +515,21 @@ 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 status: CollectionStatus | `disabled` isLoading: boolean isReady: boolean - persistedStatus: LiveQuerySnapshot[`persistedStatus`] - 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 `health`. */ + isSyncError: boolean + /** 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. */ @@ -584,9 +577,6 @@ interface CachedFrom { observerSnapshot: unknown committedPageCount: number isFetchingNextPage: boolean - hasPaginationError: boolean - paginationError: unknown - failedHasNextPage: boolean } interface SubscriptionRecord { @@ -611,9 +601,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 @@ -656,19 +643,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.isFetchingNextPage === this.isFetchingNextPage ) { return cached } @@ -679,10 +660,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> = [] @@ -692,39 +670,31 @@ 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, - persistedStatus: observerSnapshot.persistedStatus, - isPersistedReady: observerSnapshot.isPersistedReady, - persistedError: observerSnapshot.persistedError, - isIdle: statusFlags.isIdle, - isError: statusFlags.isError, + status: observerSnapshot.status, + isLoading: observerSnapshot.isLoading, + isReady: observerSnapshot.isReady, + isIdle: observerSnapshot.isIdle, + isLoadingError: observerSnapshot.isLoadingError, + isCrashed: observerSnapshot.isCrashed, isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, + isSyncError: observerSnapshot.isSyncError, + health: observerSnapshot.health, } this.cachedFrom = { observerSnapshot, committedPageCount: this.committedPageCount, isFetchingNextPage: this.isFetchingNextPage, - hasPaginationError: this.hasPaginationError, - paginationError: this.paginationError, - failedHasNextPage: this.failedHasNextPage, } return this.cachedSnapshot } @@ -742,12 +712,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 health tree. + if (windowResult !== true) void windowResult.catch(() => undefined) } catch (error) { observerUnsub?.() this.observerUnsub = null @@ -833,7 +801,6 @@ class LiveQueryWindowControllerImpl< if (this.disposed) return Promise.resolve() if ( this.committedPageCount === 1 && - !this.hasPaginationError && !this.isFetchingNextPage && !this.activeFetchPromise && this.pendingWindowGeneration === undefined @@ -847,22 +814,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() } @@ -887,14 +843,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 @@ -902,9 +855,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() @@ -933,7 +883,6 @@ class LiveQueryWindowControllerImpl< this.pendingWindowGeneration = undefined this.committedPageCount = requestedPageCount this.isFetchingNextPage = false - this.failedHasNextPage = this.getComputedHasNextPage() this.notify() this.endTransition() }, @@ -942,9 +891,6 @@ class LiveQueryWindowControllerImpl< this.beginTransition() this.pendingWindowGeneration = undefined this.isFetchingNextPage = false - this.hasPaginationError = true - this.paginationError = error - this.failedHasNextPage = previousHasNextPage this.notify() this.endTransition() } @@ -991,37 +937,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/src/persisted-readiness.ts b/packages/db/src/persisted-readiness.ts deleted file mode 100644 index 67a6ee24fa..0000000000 --- a/packages/db/src/persisted-readiness.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { LiveQueryObserverDisposedError } from './errors.js' - -/** 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 - /** - * The network-first initial-render wait, normally - * `waitForPersistedInitialRender`. A persistence adapter supplies it with - * its readiness, so an app with no persisted Collection does not ship it. - */ - waitForInitialRender: typeof waitForPersistedInitialRender -} - -/** A client query stream, as the initial-render wait reads it. */ -interface InitialRenderClientQuery { - status: string - error?: unknown - promise?: Promise -} - -/** @internal What a live query observer passes to the initial-render wait. */ -export interface PersistedInitialRenderInput { - sources: ReadonlyArray<{ - collection: { status: string } - readiness: PersistedReadinessSource - }> - /** The network preload that this render prefers. */ - network: Promise - /** The client query stream at the start of the wait, if any. */ - clientQuery: InitialRenderClientQuery | undefined - /** The client query stream now, for a failed stream's identity. */ - currentClientQuery: () => unknown - /** The live query Collection's status now. */ - collectionStatus: () => string | undefined - readPersisted: () => { status: string; error: unknown } - /** Runs once the render may proceed, before the promise resolves. */ - onReady: () => void -} - -/** - * Resolve when network data is ready, or when the network deadline passes or - * the network fails while every persisted source has restored. Reject with - * the network error when no persisted fallback may render. - */ -export function waitForPersistedInitialRender({ - sources, - network, - clientQuery, - currentClientQuery, - collectionStatus, - readPersisted, - onReady, -}: PersistedInitialRenderInput): { - promise: Promise - cancel: () => void -} { - const clientStream = - clientQuery?.promise === network ? clientQuery : undefined - 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 { - onReady() - resolve() - } - } - cancel = () => finish({ error: new LiveQueryObserverDisposedError() }) - const check = () => { - if (settled) return - if (timer !== undefined) { - clearTimeout(timer) - timer = undefined - } - if (networkReady) { - finish() - return - } - const persisted = readPersisted() - 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 && - currentClientQuery() === failedClientStream - if ( - networkFailed && - !sourceFailed && - (collectionStatus() === `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(() => { - unsubscribers.forEach((unsubscribe) => unsubscribe()) - if (timer !== undefined) clearTimeout(timer) - }) - return { promise, cancel } -} - -/** @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/effect.ts b/packages/db/src/query/effect.ts index ac6b8cda63..fe5444fa36 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 { 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' @@ -39,6 +40,9 @@ import type { } from './compiler/joins.js' import type { ChangeMessage, + CollectionDependent, + CollectionStatus, + HealthNode, KeyedStream, StringCollationConfig, } from '../types.js' @@ -131,12 +135,23 @@ 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 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 + /** + * 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. + */ + onHealthChange?: (health: HealthNode) => void + /** * Skip deltas during initial collection load. * Defaults to false (process all deltas including initial sync). @@ -199,6 +214,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, @@ -300,11 +324,20 @@ 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, + onHealthChange: config.onHealthChange, onSourceError: (error: Error) => { if (disposed) return @@ -354,12 +387,7 @@ export function createEffect< throw error } - return { - dispose, - get disposed() { - return disposed - }, - } + return effect } // --------------------------------------------------------------------------- @@ -375,6 +403,9 @@ interface EffectPipelineRunnerConfig< onBatchProcessed: (events: Array>) => void /** Called when a source collection enters error or cleaned-up state */ onSourceError: (error: Error) => void + onHealthChange?: (health: HealthNode) => void + /** The Effect's handle, so its source subscriptions can name it. */ + effect: Effect } /** @@ -458,6 +489,14 @@ class EffectPipelineRunner { // skipInitial state private readonly skipInitial: boolean private initialLoadComplete = false + // The Effect's initial gate: latched when every + // initial load has settled, with or without skipInitial. After it, + // a sync problem does not dispose the Effect. + private reachedReady = false + private reportedTree: HealthNode | undefined + private readonly onHealthChange: ((health: HealthNode) => void) | undefined + // This Effect as its sources' `subscribers` list it. + private readonly dependent: CollectionDependent // Scheduler integration private subscribedToAllCollections = false @@ -477,6 +516,12 @@ class EffectPipelineRunner { this.skipInitial = config.skipInitial this.onBatchProcessed = config.onBatchProcessed this.onSourceError = config.onSourceError + this.onHealthChange = config.onHealthChange + this.dependent = { + kind: `effect`, + active: true, + end: () => config.effect.dispose(), + } // Parse query this.query = buildQueryFromConfig({ query: config.query }) @@ -605,6 +650,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( @@ -613,16 +666,17 @@ class EffectPipelineRunner { orderByInfo, whereExpression, ), - onLoadSubsetError: ({ error }) => { - this.onSourceError(normalizeError(error)) - }, + owner: this.dependent, + onLoadSubsetError: ({ 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) => + onLoadSubsetResult: (result) => { this.trackInitialLoad( result, () => this.subscriptions[sourceId]?.pendingTruncateReplacement, - ), + ) + }, }) // Store subscription immediately so the join compiler can find it @@ -677,6 +731,10 @@ class EffectPipelineRunner { subscription, alias, (result, holdPublication) => { + this.trackInitialLoad( + result, + () => subscription.pendingTruncateReplacement, + ) const holdsInitialPublication = this.skipInitial && !this.initialLoadComplete if (holdPublication || holdsInitialPublication) { @@ -717,20 +775,16 @@ 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 - - // Source entered error state — effect can no longer function - if (status === `error`) { - this.onSourceError( - new Error( - devBuild() && process.env.NODE_ENV !== `production` - ? `Source collection '${collectionId}' entered error state` - : codedMessage(135, { collectionId }), - ), - ) + // 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(stoppedSourceError(collectionId, status)) return } @@ -748,8 +802,16 @@ class EffectPipelineRunner { ) { this.initialLoadComplete = true } - }) + // health:change follows every status change and reports the tree. + this.latchReady() + } + const statusUnsubscribe = collection.on(`status:change`, ({ status }) => + handleSourceStatus(status), + ) 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 @@ -787,6 +849,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. @@ -872,27 +935,26 @@ 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 = () => { 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() @@ -984,6 +1046,7 @@ class EffectPipelineRunner { } // Emit all accumulated events once the graph reaches quiescence this.flushPendingChanges() + this.latchReady() if ( this.skipInitial && !this.initialLoadComplete && @@ -1045,7 +1108,38 @@ class EffectPipelineRunner { ) } - /** Initial callbacks start only after every source served its initial data. */ + /** Latch the Effect's initial gate and report whether it has been reached. */ + private latchReady(): boolean { + if (!this.reachedReady && this.canCompleteInitialLoad()) + this.reachedReady = true + return this.reachedReady + } + + /** 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`, + status: this.reachedReady ? `ready` : `loading`, + failureReason: null, + error: null, + reloading: false, + sources: this.collectionSources.map(({ sourceId, alias, collection }) => + buildHealthTree(collection, { alias, id: sourceId }, this), + ), + }) + } + + private reportHealth(): void { + if (this.disposed || !this.reachedReady || !this.onHealthChange) return + // An unchanged tree keeps its identity; report each change once. + const tree = this.healthTree() + if (tree === this.reportedTree) return + this.reportedTree = tree + this.onHealthChange(tree) + } + private canCompleteInitialLoad(): boolean { return ( !this.orderedPublicationFailed && @@ -1142,7 +1236,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 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 { 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 86eae32fb9..d36cb46538 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -782,10 +782,18 @@ 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, 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 @@ -823,23 +831,26 @@ 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. 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 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 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 +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 @@ -1416,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 @@ -1475,12 +1489,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 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, @@ -1638,8 +1654,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 @@ -1650,8 +1666,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 enters the same terminal error 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 @@ -1722,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. @@ -1736,9 +1756,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, puts - dependent live queries in terminal error, 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 @@ -1750,6 +1772,64 @@ 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 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 + 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 + 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. 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 + 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 + 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, + 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 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 + 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'`; its per-row child Collections are not in the tree, + so adding a child changes nothing. Every change to the tree is announced + 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 `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 @@ -1783,30 +1863,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/bucket-facade-adapter.ts b/packages/db/src/query/live/bucket-facade-adapter.ts index 02a60b071f..24720ed922 100644 --- a/packages/db/src/query/live/bucket-facade-adapter.ts +++ b/packages/db/src/query/live/bucket-facade-adapter.ts @@ -454,7 +454,8 @@ export class BucketFacadeAdapter { startSync: true, gcTime: 0, }) - 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 a8bf06e336..932b4a81d5 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -14,6 +14,8 @@ 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 { 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' @@ -35,8 +37,10 @@ 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 { HealthSource } from '../../collection/health-tree.js' import type { CollectionConfigSingleRowOption, + CollectionDependent, KeyedStream, ResultStream, StringCollationConfig, @@ -119,13 +123,61 @@ export class CollectionConfigBuilder< public currentSyncState: FullSyncState | undefined // Error state tracking - private isInErrorState = false private fatalQueryError = false - private readonly erroredSourceIds = new Set() + // 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 public liveQueryCollection?: Collection + private dependentEntry: CollectionDependent | undefined + + /** + * 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( + query: Collection | undefined = this.liveQueryCollection, + ): Promise { + // 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 && + 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 (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. + if (query?._lifecycle.problems.error || query?._lifecycle.reloading) + 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 @@ -158,6 +210,7 @@ export class CollectionConfigBuilder< Map> | undefined private bucketFacadesCache: ReturnType[`facades`] | undefined + private readonly includeSourceIds = new Set() // Map of opaque source ID to subscription readonly subscriptions = createSourceRecord() @@ -213,6 +266,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) { @@ -444,6 +498,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) } @@ -465,6 +524,18 @@ export class CollectionConfigBuilder< return generation } + /** The source aliases of this query, in query order, for its health tree. */ + healthSources(): ReadonlyArray { + 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 @@ -492,7 +563,13 @@ export class CollectionConfigBuilder< if (!demand || demand.generation !== generation) return const normalized = this.recordSubsetError(error) demand.participant?.reject(normalized) - this.transitionToError( + 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}` : codedMessage(228, { planId, cause: normalized.message }), @@ -511,7 +588,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 }), @@ -558,7 +635,7 @@ export class CollectionConfigBuilder< if ( !holdPublication && !this.activeWindowOperation && - this.liveQueryCollection?.status !== `loading` && + this.liveQueryCollection?._lifecycle.inFirstLoad !== true && this.pendingOrderedLoads.size === 0 ) { return @@ -590,9 +667,47 @@ export class CollectionConfigBuilder< retireDemand(planId: string): void { const demand = this.activeDemands.get(planId) this.activeDemands.delete(planId) + this.clearFailedRequest(planId) demand?.participant?.resolve() } + /** 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}`) + } + + /** 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 { + const lifecycle = this.liveQueryCollection?._lifecycle + if (lifecycle?.status !== `ready`) return false + lifecycle.reportRequestError(key, error) + return true + } + + /** A request settled later, or the query no longer needs its data. */ + clearFailedRequest(key: string): void { + this.liveQueryCollection?._lifecycle.clearRequestError(key) + } + hasPendingSourceRecovery(): boolean { return Object.values(this.subscriptions).some( (subscription) => subscription.hasPendingTruncateReplacement, @@ -659,7 +774,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 } @@ -671,8 +786,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?._lifecycle.stoppedThisRun !== true + ) { + this.crashQuery( devBuild() && process.env.NODE_ENV !== `production` ? `Live query graph failed` : codedMessage(212), @@ -766,9 +886,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.erroredSourceIds.clear() this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config @@ -798,6 +916,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( @@ -834,6 +953,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 @@ -849,9 +970,7 @@ export class CollectionConfigBuilder< this.loadMoreFn = undefined this.currentWindow = undefined this.settledWindow = this.initialWindow - this.isInErrorState = false this.fatalQueryError = false - this.erroredSourceIds.clear() // Reset caches so a fresh graph/pipeline is compiled on next start // This avoids reusing a finalized D2 graph across GC restarts @@ -1042,8 +1161,12 @@ 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.openTransactionReplaces() let lookup: ((key: string | number) => boolean) | undefined const hasSyncedKey = (key: string | number) => { + if (replaces) return false lookup ??= config.collection._state.createSyncedKeyLookup() return lookup(key) } @@ -1151,13 +1274,27 @@ export class CollectionConfigBuilder< ) { const { status } = event - // Handle error state - any source collection in error puts live query in error - if (status === `error`) { - this.erroredSourceIds.add(sourceId) - this.setErrorState( + // 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 } @@ -1170,15 +1307,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, @@ -1200,7 +1328,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 } @@ -1211,7 +1339,7 @@ export class CollectionConfigBuilder< const { markReady } = config // Don't update status if already in error - if (this.isInErrorState) { + if (this.fatalQueryError) { return } @@ -1226,33 +1354,48 @@ 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() } } /** - * 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 console.error( devBuild() && process.env.NODE_ENV !== `production` ? `[Live Query Error] ${message}` : message, ) - - // Transition live query collection to error state - this.liveQueryCollection?._lifecycle.markError(error ?? new Error(message)) + return true } private allRequiredSourcesReady() { @@ -1300,6 +1443,12 @@ export class CollectionConfigBuilder< const statusUnsubscribe = collection.on(`status:change`, (event) => { this.handleSourceStatusChange(config, sourceId, collectionId, event) }) + // Relay health tree changes so this query's tree stays current. + syncState.unsubscribeCallbacks.add( + collection.on(`health:change`, () => + this.liveQueryCollection?._emitHealthChange(), + ), + ) let cleanupStartError: Error | undefined const cleanupStartUnsubscribe = collection._onCleanupStart(() => { cleanupStartError = this.handleSourceCleanupStart(collectionId) @@ -1318,12 +1467,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/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 36e1c68d43..2085dd6872 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, @@ -42,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, @@ -51,6 +56,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) @@ -75,6 +85,10 @@ 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. + this.loadOrder.track(result, () => + 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 @@ -114,14 +128,17 @@ 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. !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) + } } // Create subscription with onStatusChange - listener is registered before any async work @@ -200,12 +217,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?.status === `loading` 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 @@ -214,31 +231,23 @@ export class CollectionSubscriber< this.collectionConfigBuilder.retireDemand(plan.id) return } - const generation = this.collectionConfigBuilder.beginDemand( plan.id, update.ready instanceof Promise, ) + // 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) 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, - ) - }, - ) - 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 { - this.collectionConfigBuilder.settleDemand(plan.id, generation) + settle() } } @@ -290,6 +299,7 @@ export class CollectionSubscriber< onStatusChange, onLoadSubsetError, deferAcquisition: this.defersAcquisition(), + owner: this.owner(), truncateReplayPublication: this.truncateReplayPublicationControl(), orderBy: hints.orderBy, limit: hints.limit, @@ -334,6 +344,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 @@ -426,10 +437,25 @@ export class CollectionSubscriber< return } this.orderedLoader?.settleFullSourceReplay() + // The replay loaded every demand this subscription holds again. + this.collectionConfigBuilder.settleReplayedRequests( + this.sourceId, + this.demand.settleReplay(), + ) this.collectionConfigBuilder.scheduleGraphRunIfSyncRunCurrent( 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) + }, + 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 0b64a2585d..51d38a8d48 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -18,12 +18,18 @@ 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 } 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 = { @@ -39,9 +45,45 @@ 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() + /** + * 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) { + // Every segment the subscription holds was loaded again, also one whose + // 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) + 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 + } + setDemand( subscription: CollectionSubscription, plan: LazyDemandPlan, @@ -76,20 +118,28 @@ 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) - // Release every retired segment even if subscriber code that a release - // runs throws, then report the first error. - 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 } } @@ -105,35 +155,64 @@ export class SubsetDemandController { const replace = retryReplacement || (removed && (added.size > 0 || segments.length > 1)) const requestedKeys = replace ? nextKeys : added - if (requestedKeys.size > 0) { - const segment = createSegment(plan, requestedKeys) - if (replace) segment.replaces = [...segments] - segments.push(segment) - const started = startSegment(subscription, segment, () => - this.warnUnoptimized(plan), - ) - 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), - () => {}, + // A replacement alone decides the demand: the segments it supersedes are + // released, and aborted, once it settles. + let replacement: DemandSegment | undefined + 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) { + 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), + () => {}, + ) + } 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 = state.segments + const pending = (replacement ? [replacement] : state.segments) .filter( (segment) => segment.state === `pending` && intersects(segment.keys, nextKeys), @@ -147,6 +226,23 @@ export class SubsetDemandController { } } + // 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?.segments.includes(segment)) return + for (const key of segment.keys.keys()) { + 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 { for (const state of this.states.values()) { for (const segment of state.segments) segment.abortController.abort() @@ -258,6 +354,7 @@ function startSegment( subscription: CollectionSubscription, segment: DemandSegment, onUnoptimized: () => void, + onOutcome: (succeeded: boolean) => void, ): boolean { let observed = false const requested = subscription.requestSnapshot({ @@ -267,7 +364,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` }, }) @@ -275,15 +373,48 @@ function startSegment( void segment.ready.then( () => { segment.state = `settled` + onOutcome(true) }, () => { segment.state = `failed` + onOutcome(false) }, ) } return requested && observed } +// A truncate's or a reload's replay replaces a load that it aborts: the replay +// 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, +): Promise { + const replacements = subscription.replacementCount + const replaced = () => { + const replay = subscription.pendingTruncateReplacement + return replay && followReplay(subscription, replay) + } + const decide = (error: unknown): Promise | undefined => { + if (replacements !== subscription.replacementCount) return replaced() + const replay = subscription.pendingTruncateReplacement + if (!replay) throw error + // The load joined the replay: its own failure stands once the replay + // settles, unless a newer replay replaced it meanwhile. + return replay.then( + () => decide(error), + () => decide(error), + ) + } + return ready.then( + () => + replacements === subscription.replacementCount ? undefined : replaced(), + decide, + ) +} + function releaseSegment( subscription: CollectionSubscription, segment: DemandSegment, diff --git a/packages/db/src/query/pooled-live-query.ts b/packages/db/src/query/pooled-live-query.ts index 9f87605ff3..d0020bc1ab 100644 --- a/packages/db/src/query/pooled-live-query.ts +++ b/packages/db/src/query/pooled-live-query.ts @@ -1,9 +1,13 @@ 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 { buildHealthTree, healthNode } from '../collection/health-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' @@ -13,7 +17,13 @@ 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, + CollectionDependent, + CollectionErrorInfo, + CollectionStatus, + HealthNode, +} from '../types.js' import type { CollectionEventHandler, CollectionStatusChangeEvent, @@ -70,9 +80,28 @@ 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 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, @@ -135,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 @@ -157,24 +187,17 @@ 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) }) - // 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. - 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: `error`, - }) - }) + // 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(() => + this.end(`crashed`), + ) this.stopStatusEvents = () => { stopStatus() stopCleanupStart() @@ -182,9 +205,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: `error` as const } + ? { ...event, status: this.terminated } : event for (const listener of [...this.statusListeners]) listener(delivered) if (this.terminated) { @@ -206,12 +247,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 @@ -226,6 +261,18 @@ class Partition { CleanupQueue.getInstance().schedule(this, delay, this.releaseIfUnused) } + /** + * 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. + */ + releaseView(): void { + if (--this.views > 0) return + CleanupQueue.getInstance().cancel(this) + this.releaseIfUnused() + } + private readonly releaseIfUnused = (): void => { if (this.listenerCount > 0) return this.stopStatusEvents?.() @@ -484,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( @@ -494,12 +543,48 @@ 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) } get status(): CollectionStatus { - return this.partition.terminated ? `error` : this.source.status + return this.partition.terminated ?? this.source.status + } + + private endedProblem: CollectionErrorInfo | undefined + + /** 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 = buildHealthTree( + 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 = + this.source._lifecycle.statusProblem ?? + (this.partition.terminated === `crashed` + ? (this.endedProblem ??= describeProblem( + new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `The pooled live query's source ended` + : codedMessage(246), + ), + )) + : null) + return healthNode(this, `view`, { + alias: `root`, + collectionId: this.source.id, + kind: `live-query`, + status: this.status, + failureReason: null, + error, + reloading: false, + sources: [source], + }) } // Read by key: the partition drops a group nobody watches once it empties. @@ -600,8 +685,13 @@ 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() + if (!this.released) { + this.released = true + this.partition.releaseView() + } + return cleanup } private proxy: Collection | undefined = undefined @@ -678,13 +768,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() @@ -722,5 +807,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/src/types.ts b/packages/db/src/types.ts index 298543dc42..c440c3e08d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -462,13 +462,28 @@ 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()`. + * 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 + /** + * 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()`. + */ + 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()`. + */ + reportHealthy: () => void /** * Replace the synced rows. By default the replacement also marks the * Collection ready. Set markReady:false to publish without changing status, @@ -742,8 +757,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 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`. */ export type CollectionStatus = /** Collection is created but sync hasn't started yet (when startSync config is false) */ @@ -752,13 +768,62 @@ 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, and `reload()` rejects. Only cleanup leaves it; a + * later start is a fresh start. + */ + | `crashed` /** Collection has been cleaned up and resources freed */ | `cleaned-up` 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 { + readonly name: string + readonly message: string + readonly code?: string + /** Epoch milliseconds when the problem was reported. */ + readonly at: number + readonly cause: unknown +} + +/** + * 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. + */ +export interface HealthNode { + /** The query alias, or `root` for the Collection the tree was read from. */ + readonly alias: string + readonly collectionId: string + /** `include` is an include's source; its rows feed per-row child Collections. */ + readonly kind: `source` | `live-query` | `include` + readonly status: CollectionStatus + /** This node or any node below it has a `failureReason` or an `error`. */ + 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 + readonly sources: ReadonlyArray +} + export interface BaseCollectionConfig< T extends object = Record, TKey extends string | number = string | number, @@ -1110,6 +1175,27 @@ export type KeyedNamespacedRow = [unknown, NamespacedRow] */ export type NamespacedAndKeyedStream = IStreamBuilder +/** + * 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 +} + /** * Options for subscribing to collection changes */ @@ -1145,6 +1231,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. @@ -1173,6 +1265,10 @@ export interface SubscribeChangesOptions< truncateReplayPublication?: { readonly start: () => void 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/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index 356f91cbf0..9f5e5497e5 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 }) @@ -481,7 +580,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({ @@ -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,9 +630,10 @@ 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(`error`) + expect(live.status).toBe(`crashed`) expect(reports).toHaveBeenCalledTimes(1) cleanupGate.resolve() @@ -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`) @@ -894,7 +998,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, @@ -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(`error`) + // 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(`error`) - 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() @@ -1033,7 +1141,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 +1183,23 @@ describe(`Collection cleanup admission oracle`, () => { await live.preload() expect(live.get(1)?.rank).toBe(1) - sourceSync.markError(new Error(`source sync failed`)) - expect(source.status).toBe(`error`) - expect(live.status).toBe(`error`) + // After the first ready a source's fail() stops its sync: the + // 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.health.error).toMatchObject({ + message: `source sync failed`, + }) + expect(live.status).toBe(`ready`) + expect(live.get(1)?.rank).toBe(1) + 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.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/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index 2efca4d202..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,10 +612,11 @@ describe(`Collection Error Handling`, () => { ), ).not.toThrow() expect(() => - collectionImpl._lifecycle.validateStatusTransition(`ready`, `error`), + collectionImpl._lifecycle.validateStatusTransition(`ready`, `crashed`), ).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..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() }, }, @@ -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,12 +759,13 @@ 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.markError(syncFailure) + collection._lifecycle.setStatus(`loading`) + collection._lifecycle.fail(syncFailure) expect(collection._lifecycle.getSyncError()).toBe(syncFailure) } else if (from === `cleaned-up`) { collection._lifecycle.setStatus(`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,14 +887,16 @@ describe(`Collection Lifecycle Management`, () => { ) const firstReady = vi.fn() collection.onFirstReady(firstReady) + // An internal failure during the ready transition still ends it. A + // source's fail at this point would be health metadata instead. collection.on(`status:ready`, () => { - collection._lifecycle.markError(failure) + collection._lifecycle.crash(failure) }) collection._lifecycle.setStatus(`loading`) 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() @@ -1181,13 +1171,13 @@ 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() } }) - 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 }>({ @@ -1206,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 @@ -1217,9 +1207,11 @@ describe(`Collection Lifecycle Management`, () => { thrown = error } - expect(didThrow).toBe(true) + // 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(`ready`) + 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 bd437d563e..3afe5cd9fb 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -2186,7 +2186,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`, async () => { @@ -2928,7 +2928,7 @@ describe(`Virtual properties`, () => { expect(liveRow.$collectionId).toBe(`virtual-props-source`) liveSub.unsubscribe() - await source.cleanup() + await source.cleanup({ dependents: true }) await live.cleanup() }) @@ -2979,7 +2979,7 @@ describe(`Virtual properties`, () => { ).toBe(false) liveSub.unsubscribe() - await source.cleanup() + await source.cleanup({ dependents: true }) await syncedOnly.cleanup() }) @@ -3051,7 +3051,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/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 6c51de566c..feb43610db 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -781,7 +781,7 @@ export type CleanedRestartDemandCase = { syncMode: `default-eager` | `eager` | `on-demand` loader: `absent` | `present` replacement: `empty` | `row` - interruption: `none` | `truncate` | `recoverable-error` + interruption: `none` | `truncate` | `failed-then-reload` } export const cleanedRestartDemandCases: ReadonlyArray = @@ -789,7 +789,7 @@ export const cleanedRestartDemandCases: ReadonlyArray ...([`empty`, `row`] as const).flatMap((replacement) => ([`default-eager`, `eager`] as const).flatMap((syncMode) => ([`absent`, `present`] as const).flatMap((loader) => - ([`none`, `truncate`, `recoverable-error`] as const).map( + ([`none`, `truncate`, `failed-then-reload`] as const).map( (interruption) => ({ syncMode, loader, @@ -849,7 +849,7 @@ export function expectedCleanedRestartDemand( result: `pending` as const, status: `loadingSubset` as const, collectionStatus: - history.interruption === `recoverable-error` + history.interruption === `failed-then-reload` ? (`error` as const) : (`loading` as const), }, diff --git a/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts index df7a9775d5..12a9badc22 100644 --- a/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts @@ -55,7 +55,7 @@ const randomCampaign = (property: string) => * The independent model in the lifecycle grammar crosses explicit eager, * default eager, and on-demand configuration with empty and nonempty * replacements and with or without an eager loader. Eager histories may also - * truncate the loading replacement or enter recoverable error before ready. + * truncate the loading replacement, or fail their first load and reload. * The production driver publishes an old row, creates a direct subscription, * cleans up its source, requests a snapshot, and restarts the real Collection. * It holds eager readiness to prove the cleanup-detached @@ -169,8 +169,8 @@ describe(`demand created while its Collection is cleaned up`, () => { operations.truncate({ markReady: false }) const receipt = operations.commit() if (receipt !== true) await receipt - } else if (history.interruption === `recoverable-error`) { - operations.markError(new Error(`recoverable restart failure`)) + } else if (history.interruption === `failed-then-reload`) { + operations.fail(new Error(`restart failure`)) } if (expected.afterInterruption) { await flushPromises() @@ -182,6 +182,12 @@ describe(`demand created while its Collection is cleaned up`, () => { }).toEqual(expected.afterInterruption) } + // A failed first load ends its sync run; only a reload starts the + // run that can serve the waiting demand. + if (history.interruption === `failed-then-reload`) { + void collection.reload().catch(() => undefined) + await flushPromises() + } releaseEagerReady!() await flushPromises() } diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 0a7ae23cca..74f2f30997 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 { @@ -64,7 +65,7 @@ const acquisitionEntries = [ `request`, `resume`, `markReady`, - `markError`, + `fail`, `syncReturn`, ] as const type AcquisitionPhase = (typeof acquisitionPhases)[number] @@ -81,7 +82,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`, }, @@ -95,7 +96,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': { @@ -103,7 +104,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`, @@ -117,7 +118,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`, }, @@ -134,7 +135,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`, }, @@ -145,10 +146,13 @@ 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: `covered` }, - 'unavailable:markError': { + 'unavailable:markReady': { + kind: `excluded`, + reason: `markReady after fail in one sync attempt is a contract violation and throws`, + }, + 'unavailable:fail': { kind: `excluded`, reason: `a repeated error leaves acquisition unavailable`, }, @@ -2060,7 +2064,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({ @@ -2075,7 +2079,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() @@ -2771,7 +2775,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) @@ -2982,8 +2986,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:fail`], + `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 = [] @@ -2994,8 +2998,8 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { id: `sync-entry-error-ready-recovery`, startSync: false, sync: { - sync: ({ markError, markReady }) => { - if (syncRunCount++ === 0) { + sync: ({ fail, markReady }) => { + if (syncRunCount++ !== 1) { markReady() return { loadSubset: (options) => { @@ -3006,8 +3010,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) @@ -3038,25 +3042,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`, @@ -3086,7 +3096,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`) @@ -3133,9 +3143,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, @@ -3178,7 +3186,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`) { @@ -3243,17 +3251,17 @@ 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 + 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) => { @@ -3270,12 +3278,13 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { includeInitialState: false, }) - markError(new Error(`initial sync failed`)) - markReady() + fail(new Error(`initial sync failed`)) + 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() @@ -3285,14 +3294,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) => { @@ -3309,9 +3318,12 @@ 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. + await collection.cleanup() + collection.startSyncImmediate() markReady() await flushPromises() @@ -3323,20 +3335,20 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }) acquisitionCase( - [`on-demand:markError`], - `defers demand while an installed loader is in initial error`, + [`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) => { @@ -3355,20 +3367,32 @@ 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`) 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() }, @@ -3486,7 +3510,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 aa7d56a922..7614a13d3b 100644 --- a/packages/db/tests/conformance-disabled-laws.test.ts +++ b/packages/db/tests/conformance-disabled-laws.test.ts @@ -11,8 +11,11 @@ function disabled( state: representation === `absent` ? undefined : new Map(), status: `disabled`, isReady: true, - isError: false, + isLoadingError: false, + isCrashed: false, + isSyncError: false, isEnabled: false, + health: undefined, } } @@ -45,7 +48,23 @@ const faults: Array<[string, (value: ConformanceResult) => void]> = [ [ `error`, (value) => { - value.isError = true + value.isLoadingError = true + }, + ], + [ + `stale health tree`, + (value) => { + value.health = { + alias: `root`, + collectionId: `stale`, + kind: `live-query`, + status: `ready`, + hasErrors: false, + failureReason: null, + error: null, + reloading: false, + 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 92075b4187..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 } from '@tanstack/db' +import type { Collection, HealthNode } from '@tanstack/db' /** Default row shape used by the base scenarios (a "person"). */ export interface Row { @@ -78,6 +78,21 @@ 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 reports. + */ +export interface SyncSourceHandle< + T extends { id: string } = Row, +> extends SourceHandle { + /** The sync adapter reports a problem and keeps trying: a `failureReason`. */ + reportError: (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 +} + /** * The subset of `@tanstack/db` query operators scenarios need, supplied by the * driver from the adapter's realm. Grows as engine scenarios are ported. @@ -108,11 +123,17 @@ export interface ConformanceResult { state: ReadonlyMap | undefined status: string isReady: boolean - 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 health tree has a problem. */ + isSyncError: boolean isEnabled: boolean + /** + * The hook's health tree, read without normalization so scenarios can judge + * object identity. `undefined` only for a disabled query. + */ + health: HealthNode | undefined } /** A query-builder callback, e.g. `(q) => q.from({ items: source.collection })`. */ @@ -166,6 +187,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 reports the scenario controls. */ + makeSyncSource: ( + initialData: ReadonlyArray, + ) => SyncSourceHandle /** Create an on-demand source that lags behind its settled subset (keyed by `id`). */ makeLaggingSource: () => LaggingSourceHandle /** @@ -176,8 +201,12 @@ export interface LiveQueryDriver { build: QueryBuild, opts?: { startSync?: boolean; gcTime?: number }, ) => { 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 } @@ -205,7 +234,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 a28b23c862..56ca25d9dc 100644 --- a/packages/db/tests/conformance/disabled-laws.ts +++ b/packages/db/tests/conformance/disabled-laws.ts @@ -9,7 +9,9 @@ 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.health).toBeUndefined() if (representation === `absent`) { expect(result.data).toBeUndefined() expect(result.state).toBeUndefined() 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 aa8a1f37fc..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 - error: unknown + 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 0cb95e8f33..4a2df1b756 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 health 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().isLoadingError).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/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 6882c980ee..b89f42ea98 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -33,11 +33,11 @@ * 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 + * 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' -import { waitForPersistedInitialRender } from '@tanstack/db' import { ScenarioLifetime } from './scenario-lifetime' import { ScenarioSources } from './scenario-sources' import { scenarioRegistry } from './registration' @@ -48,12 +48,7 @@ import { expectUnorderedRows, selectedRow, } from './result-laws' -import type { - DeferredSourceHandle, - LiveQueryDriver, - LiveQueryHandle, - Row, -} from './contract' +import type { LiveQueryDriver, LiveQueryHandle, Row } from './contract' const ADAPTER_OWNED_GC_TIME_MS = 1 type ConformanceCollection = NonNullable< @@ -118,9 +113,11 @@ export function runSuite(rawDriver: LiveQueryDriver) { sources!.track(rawDriver.makeDeferredSource()), makeLaggingSource: () => sources!.track(rawDriver.makeLaggingSource()), + 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)), @@ -131,40 +128,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, - waitForInitialRender: waitForPersistedInitialRender, - 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, @@ -195,91 +158,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( @@ -1208,28 +1086,217 @@ export function runSuite(rawDriver: LiveQueryDriver) { }, ) + // 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`, + ) + 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 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.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.health?.error?.cause).toBe(source.expectedError) + } + h.unmount() + }, + ) + } + + // ---- sync problems after readiness ------------------------------- + // After readiness a sync problem never changes status, rows, or the + // 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 + // 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 + // 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( - `error-status`, - `a failing source surfaces an error (flag or boundary)`, + `sync-errors-after-readiness`, + `sync problems after readiness appear only in the health tree`, 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, + type Problem = { + field: `failureReason` | `error` + cause: Error + serial: number + } + let problem: Problem | null = null + let serial = 0 + const stopped = () => problem?.field === `error` + + const source = driver.makeSyncSource(SEED) + const h = driver.mount((q) => + q + .from({ items: source.collection }) + .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 + + const observe = (step: string) => { + const result = h.current() + const tree = result.health + expect(result.status, step).toBe(`ready`) + expect( + result.isLoadingError, + `${step}: isLoadingError is the first load`, + ).toBe(false) + expect(result.isSyncError, `${step}: isSyncError`).toBe( + problem !== null, ) - const h = driver.mountCollection(source.collection) + 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( + { + rootError: tree!.error, + hasErrors: tree!.hasErrors, + node: node && { + kind: node.kind, + status: node.status, + failureReason: shown(node.failureReason), + error: shown(node.error), + }, + }, + step, + ).toEqual({ + rootError: null, + hasErrors: problem !== null, + node: { + kind: `source`, + status: `ready`, + 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. + 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) + 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 + } + + observe(`ready`) + await h.flush() + observe(`a flush without changes`) + + source.insert({ id: `4`, name: `Dave`, age: 40, team: `b` }) + rows = [...rows, { id: `4`, name: `Dave` }] await h.flush() + observe(`a row write`) - 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 { - expect(h.current().status).toBe(`error`) - expect(h.current().isError).toBe(true) + // Each step either applies to the model or, after fail(), throws. + const steps: Array<[string, () => void]> = [ + [ + `a failure reason`, + () => { + const stalled = new Error(`stream stalled`) + source.reportError(stalled) + problem = { + field: `failureReason`, + cause: stalled, + serial: ++serial, + } + }, + ], + [ + `reportHealthy clears it`, + () => { + source.reportHealthy() + problem = null + }, + ], + [ + `reportHealthy with nothing to clear changes nothing`, + () => source.reportHealthy(), + ], + [ + `fail sets the error`, + () => { + const ended = new Error(`stream ended`) + source.fail(ended) + problem = { field: `error`, cause: ended, serial: ++serial } + }, + ], + [`reportHealthy after fail throws`, () => source.reportHealthy()], + [ + `reportError after fail throws`, + () => source.reportError(new Error(`late`)), + ], + ] + for (const [step, act] of steps) { + const throws = stopped() + if (throws) expect(act, `${step}: throws`).toThrow() + else 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 after fail`) h.unmount() }, ) @@ -1271,7 +1338,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(37) + expect(registry.size).toBe(36) }) }) } diff --git a/packages/db/tests/conformance/sync-source.ts b/packages/db/tests/conformance/sync-source.ts new file mode 100644 index 0000000000..d5263efa74 --- /dev/null +++ b/packages/db/tests/conformance/sync-source.ts @@ -0,0 +1,54 @@ +import type { Collection } from '@tanstack/db' +import type { SyncSourceHandle } from './contract' + +/** + * 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 makeSyncSource( + createCollection: (options: any) => Collection, + id: string, + initialData: ReadonlyArray, +): SyncSourceHandle { + let controls: + | { + begin: () => void + write: (message: { type: string; value: T }) => void + commit: () => void + markReady: () => void + fail: (error: unknown) => void + reportError: (error: unknown) => 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) => controls!.reportError(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..83c1583fd8 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({ @@ -479,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 }) } }, ) @@ -556,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 }) } }, ) @@ -605,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 81f7b61e51..ab7fe6564f 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -2313,7 +2313,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 @@ -2331,19 +2331,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(() => {}) @@ -2356,8 +2358,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) @@ -2385,7 +2387,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) @@ -2413,7 +2417,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/facade-retention.probe.ts b/packages/db/tests/facade-retention.probe.ts index 05d4daba78..68aab37957 100644 --- a/packages/db/tests/facade-retention.probe.ts +++ b/packages/db/tests/facade-retention.probe.ts @@ -24,7 +24,14 @@ function capture( const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `retention-probe`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: false }], + [ + { + edgeId: `children`, + rows, + activeBuckets, + hasOrderBy: false, + }, + ], () => {}, ) graph.finalize() 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 0f3054450a..17be83ad3d 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 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." + ], "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/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 4dd1df77c5..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", @@ -928,5 +928,32 @@ "file": "query/compiler/index.ts", "template": "A windowed or findOne result row has no include route", "literals": ["A windowed or findOne result row has no include route"] + }, + "245": { + "file": "collection/lifecycle.ts", + "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 on sync after the first ready, but Collection \"", + "\" is ", + ". 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 439c095e83..f451a5af16 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 0f8e991d0b..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 @@ -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 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' @@ -55,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 }, }), @@ -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 as the Collection error while its callback fulfills', async () => { await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -224,8 +225,11 @@ 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 stops sync but keeps status. + expect(record.statuses).toContain('sync-error') + expect(receiver.status).toBe('ready') + expect(receiver.health.error).toBeTruthy() + expect(receiver.health.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 c949b152cc..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, - 'subscribeChanges' | 'on' | 'values' | 'status' + 'subscribeChanges' | 'on' | 'values' | 'status' | 'health' >, id: string, sink: (event: RecordedEvent) => void = () => {}, @@ -40,6 +40,12 @@ export function recordCollection( statuses.push(status) append({ type: 'status', status }) }) + // 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('health:change', () => { + if (collection.status === 'ready' && collection.health.error) + statuses.push('sync-error') + }) const subscription = collection.subscribeChanges( (changes) => { const publication: Publication = { @@ -69,6 +75,7 @@ export function recordCollection( events, stop: () => { stopStatus() + 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 cd3399bc3b..d35d082cc8 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, @@ -878,13 +878,20 @@ it('reports connection closure immediately and supports fresh Collections', asyn stores: ['items'], }) h.descriptors.push(upgraded) - expect(old.status).toBe('error') - expect(live.status).toBe('error') + // 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('crashed') + expect(live.status).toBe('crashed') + expect(live.health.sources[0]).toMatchObject({ + 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.health.hasErrors).toBe(true) expect(fresh.status).toBe('ready') assertRows( fresh.values(), @@ -995,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..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 }) } }) } @@ -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( @@ -1096,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) @@ -1187,7 +1189,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 c9a7023936..717a455570 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -8,7 +8,6 @@ import { createLiveQueryWindowController, normalizeLiveQueryWindowPageSize, } from '../src/live-query-window-controller.js' -import { waitForPersistedInitialRender } from '../src/persisted-readiness.js' import { mockSyncCollectionOptions } from './utils.js' import { evaluateReferenceExpression } from './reference-expression-oracle.js' import type { Collection } from '../src/collection/index.js' @@ -86,48 +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, - waitForInitialRender: waitForPersistedInitialRender, - 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) => @@ -498,16 +455,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) @@ -516,11 +470,10 @@ describe(`createLiveQueryWindowController`, () => { }) await expect(controller.preload()).rejects.toBe(failure) - expect(controller.getSnapshot().error).toBe(failure) + expect(controller.getSnapshot().isLoadingError).toBe(false) await controller.preload() - expect(controller.getSnapshot().isError).toBe(false) - expect(controller.getSnapshot().error).toBeUndefined() + expect(controller.getSnapshot().isLoadingError).toBe(false) controller.dispose() }) @@ -608,7 +561,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().isLoadingError).toBe(false) await controller.fetchNextPage() } else { resolveExpansion() @@ -715,7 +668,12 @@ 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().isLoadingError).toBe(false) + expect(controller.getSnapshot().isSyncError).toBe(true) + expect(controller.getSnapshot().health?.error).toMatchObject({ + message: failure.message, + }) expect(lq.utils.lastSubsetError).toBe(failure) expect(lq.utils.customUtility()).toBe(`custom`) controller.dispose() @@ -861,7 +819,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().isLoadingError).toBe(false) expect(ids(controller.getSnapshot())).toEqual([`1`, `2`]) await controller.reset() } else { @@ -869,7 +827,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`]) @@ -1269,8 +1227,7 @@ describe(`createLiveQueryWindowController`, () => { rejectFirst(new Error(`stale attachment failed`)) await flush() - expect(controller.getSnapshot().isError).toBe(false) - expect(controller.getSnapshot().error).toBeUndefined() + expect(controller.getSnapshot().isLoadingError).toBe(false) controller.dispose() }) @@ -1432,7 +1389,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, @@ -1445,12 +1402,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, + isLoadingError: false, isCleanedUp: false, }) controller.dispose() @@ -1525,6 +1483,6 @@ describe(`createLiveQueryWindowController`, () => { unsubscribe() controller.dispose() - await source.cleanup() + await source.cleanup({ dependents: true }) }) }) 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/oracle-config.ts b/packages/db/tests/oracle-config.ts index 46e6efb6b2..11d9ca80ab 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -32,6 +32,10 @@ const staticOracleProperties = [ `sqlite-persistence.request-local-failure-buffered-source`, `sqlite-persistence.optimistic-history`, `trailbase.lifecycle`, + `source-health.history`, + `source-health.on-demand`, + `source-health.lazy-demand`, + `source-health.request-errors`, `trailbase.page-event`, `electric.bound-descriptor-history`, `electric.persisted-tag-history`, 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/bucket-facade-adapter.test.ts b/packages/db/tests/query/bucket-facade-adapter.test.ts index 5c1564869e..f234bebeef 100644 --- a/packages/db/tests/query/bucket-facade-adapter.test.ts +++ b/packages/db/tests/query/bucket-facade-adapter.test.ts @@ -67,7 +67,14 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `public-membership`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: false }], + [ + { + edgeId: `children`, + rows, + activeBuckets, + hasOrderBy: false, + }, + ], () => {}, ) graph.finalize() @@ -123,7 +130,14 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-read-work`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: ordered }], + [ + { + edgeId: `children`, + rows, + activeBuckets, + hasOrderBy: ordered, + }, + ], () => {}, ) graph.finalize() @@ -202,7 +216,14 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-order-parent`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: true }], + [ + { + edgeId: `children`, + rows, + activeBuckets, + hasOrderBy: true, + }, + ], () => {}, ) graph.finalize() @@ -248,7 +269,14 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-rollback-parent`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: true }], + [ + { + edgeId: `children`, + rows, + activeBuckets, + hasOrderBy: true, + }, + ], () => {}, ) graph.finalize() @@ -509,7 +537,14 @@ describe(`BucketFacadeAdapter`, () => { const activeBuckets = graph.newInput<[string, true]>() const adapter = new BucketFacadeAdapter( `facade-index-rollback-parent`, - [{ edgeId: `children`, rows, activeBuckets, hasOrderBy: false }], + [ + { + edgeId: `children`, + rows, + activeBuckets, + hasOrderBy: false, + }, + ], () => {}, ) graph.finalize() diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index f146360acd..c87872fbe9 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.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 99f211ceb6..4c09159c74 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. Nothing subscribes to the live + // query, so the source's cleanup is admitted and ends the query. await usersCollection.cleanup() - expect(activeUsers.status).toBe(`error`) + expect(activeUsers.status).toBe(`crashed`) await activeUsers.cleanup() expect(activeUsers.status).toBe(`cleaned-up`) @@ -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.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/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index f537b670d3..de37d793f4 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 @@ -204,8 +204,13 @@ describe(`loadSubset replay refinement`, () => { expect(readEvents()).toEqual(initial) publications.length = 0 - await source.cleanup() - if (live) expect(live.status).toBe(`error`) + // 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) @@ -233,7 +238,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 @@ -1244,6 +1249,9 @@ type OrderedReach = { markerTruncates: number abortedMoves: number terminalErrors: number + // A failed load after initial-query readiness left a request error on the + // query's own node, and the query stayed ready (ARCHITECTURE.md law 16). + requestErrors: number } const ordinaryOrderedScenario: OrderedScenario = { @@ -1395,6 +1403,7 @@ async function checkOrderedReplay( markerTruncates: 0, abortedMoves: 0, terminalErrors: 0, + requestErrors: 0, } type Marker = { id: number; rowId: number } let markerSync!: Parameters[`sync`]>[0] @@ -1700,6 +1709,7 @@ async function checkOrderedReplay( try { await live.preload() await peer?.preload() + let sawRequestError = false for (const command of history) { try { switch (command.type) { @@ -1819,7 +1829,12 @@ async function checkOrderedReplay( swallow(error) } await flushPromises() + // A failed load after initial-query readiness shows as a request error + // until a later load of its data succeeds. + if (live.status === `ready` && live.health.error !== null) + sawRequestError = true } + if (sawRequestError) reach.requestErrors++ holding = false throwNextUnload = false await settleAll() @@ -1919,13 +1934,13 @@ describe(`Ordered windows across truncate replays`, () => { // - a window move that retires lazy joined demand whose release throws (the // subscription reported the release failure as a load error, so the move // rejected with it; core now reports it uncaught); - // - a held marker demand load that fails (the model expected convergence, - // but a failed lazy join demand is fatal by design; this replay keeps the - // terminal-error branch reached); + // - a held marker demand load that fails (after initial-query readiness a + // failed lazy join demand is a request error on the query's node, and the + // window still converges; this replay keeps that branch reached); // - two window moves while a load is held (the model let the older move, // which settled later, restore its window; the newest move wins); - // - an inner join whose lazy root demand load fails (the model allowed a - // terminal error only for marker loads; either side can be lazy); + // - an inner join whose lazy root demand load fails (either side can be + // lazy, and either side's failure is a request error); // - a window move that a deferred newer move supersedes (it resolved when // its own load settled, before the newer move resumed, so awaiting it // showed neither window); @@ -2025,7 +2040,7 @@ describe(`Ordered windows across truncate replays`, () => { joined: `some`, markerMode: `on-demand`, }, - reaches: `terminalErrors`, + reaches: `requestErrors`, }, { name: `two window moves while a load is held`, @@ -2050,7 +2065,7 @@ describe(`Ordered windows across truncate replays`, () => { { type: `move`, id: 1, rank: 1 }, ], scenario: { ...ordinaryOrderedScenario, limit: 1, joined: `inner` }, - reaches: `terminalErrors`, + reaches: `requestErrors`, }, { name: `a window move that a deferred newer move supersedes`, @@ -2119,6 +2134,7 @@ describe(`Ordered windows across truncate replays`, () => { markerTruncates: 0, abortedMoves: 0, terminalErrors: 0, + requestErrors: 0, } await fc.assert( fc.asyncProperty( @@ -2139,11 +2155,16 @@ describe(`Ordered windows across truncate replays`, () => { // starts, and while it settles; loads fail and stay held across a // truncate; and the settled-window law is checked. // An aborted move needs a truncate to land inside a held move, and a - // terminal error needs a held marker demand load to fail; the named - // replays above reach both. + // request error needs a held demand load to fail after readiness; the + // named replays above reach both. A terminal error needs a failed first + // load. if (campaign !== `replay`) { for (const [dimension, count] of Object.entries(total)) { - if (dimension === `abortedMoves` || dimension === `terminalErrors`) + if ( + dimension === `abortedMoves` || + dimension === `terminalErrors` || + dimension === `requestErrors` + ) continue expect(count, dimension).toBeGreaterThan(0) } 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..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 = () => { @@ -203,7 +243,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 +274,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(`error`) - expect(pooledView.observer.getSnapshot().status).toBe(`error`) + 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/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index 8872aad5bc..12e07b4e05 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -357,7 +357,16 @@ 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.health).toMatchObject({ + hasErrors: true, + failureReason: null, + error: expect.anything(), + }) + } if (failureValue === `error`) { expect(live.utils.lastSubsetError).toBe(error) } else { 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..34ee998e3c --- /dev/null +++ b/packages/db/tests/source-health-oracle.test.ts @@ -0,0 +1,6664 @@ +/** + * # Source failure and the health tree + * + * A Collection answers two questions on two separate axes: + * + * - **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 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 + * 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, 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.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 `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 `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'`. 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. + * + * The IndexedDB and persistence oracles own the causes of a crash; this + * oracle drives the transition directly. + * + * 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 status and its problem after + * readiness (none, a failure reason, or an error), with a serial for each + * 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 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 + * 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 + * `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. `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. `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 + * update for a key it has. `effect` disposes the Effect over A and creates a + * 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. 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 + * 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 + * 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. + * + * ## The comparison + * + * 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 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 `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. `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 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 + * + * 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 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). 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 + * Collections. Grammar exclusions are listed with the history grammar. + */ +import { fc, test as fcTest } from '@fast-check/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' +import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' +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 { 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' +import type { + CollectionStatus, + HealthNode, + LoadSubsetOptions, + SyncConfig, +} from '../src/types.js' + +// `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 = + `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` + | `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; 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; markReady?: boolean } + | { kind: `restart` } + | { 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` | `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 } +// `cause` is the message of what put the gate in `error` or `crashed`. +// `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. +// `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. +// `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 + 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. +// `start`: the query a cascade ended starts once its reloaded sources settle. +type PendingSelfReload = { + target: `query` | `inner` | `start` + waits: Set + failed: boolean +} +type RowKey = 1 | 2 | 3 +// 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 + marksReady?: boolean +} +type Model = { + sources: Record + query: GateModel + inner: GateModel + 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 + // 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 +// 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 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 } + +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 + effect.errors++ + } 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 + if (shape === `nested` && model.innerEnded) { + model.innerEnded = false + model.inner = startQuery(model, [`A`]) + } else model.innerEnded = false + // 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 = + 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, { + gate: `loading`, + cause: null, + reloading: false, + failure: null, + stopped: null, + failureFromReload: false, + cleaning: false, + retired: false, + retiredCleanup: source.retiredCleanup, + 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 + // 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, + reloading: false, + failure: null, + stopped: null, + failureFromReload: false, + rows: new Map(), + open: null, + armed: false, + }) + // 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 + } + deriveQueries(shape, model) +} + +// 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 ?? [])]) +} + +function isReload(kind: string): 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( + counter: { serial: number }, + source: SourceModel, + step: Extract, +): void { + const tag = ++counter.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 { + 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. +// 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`) { + completeReload(source) + 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` + else if (source.gate === `loading`) source.gate = `ready` + return `ok` +} + +const shapeSources: Record> = { + direct: [`A`], + join: [`A`, `B`], + 'self-join': [`A`], + nested: [`A`, `B`], + include: [`A`, `B`], + 'pooled-nested': [`A`], +} + +function freshSource(): SourceModel { + return { + gate: `loading`, + cause: null, + reloading: false, + failure: null, + stopped: null, + failureFromReload: false, + cleaning: false, + retired: false, + retiredCleanup: false, + rows: new Map(), + open: null, + armed: false, + } +} + +function freshModel(): Model { + return { + sources: { A: freshSource(), B: freshSource() }, + query: { gate: `loading`, cause: null, reloading: false }, + inner: { gate: `loading`, cause: null, reloading: false }, + serial: 0, + selfReloads: [], + effect: { live: true, gate: false, errors: 0 }, + queryEnded: false, + innerEnded: false, + queryDetached: false, + } +} + +// 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 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, +): GateModel { + if (current.gate === `crashed`) return current + const crashed = inputs.find(({ gate }) => gate === `crashed`) + if (crashed) + 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` && !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 } + return current +} + +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`) { + 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. + model.inner = deriveGate(model.inner, gates([`A`])) + model.query = model.inner + } else { + model.query = deriveGate(model.query, gates(shapeSources[shape])) + } +} + +/** + * 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`) { + // 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`) { + // 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) + 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` + if (model.queryEnded) { + // 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` + // The query reloads every Collection below it that shows an error, + // through any nested live query; a reload in flight keeps its run. + const reloaded = shapeSources[shape].filter((name) => { + const source = model.sources[name] + return source.gate === `error` || source.stopped !== null + }) + 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. + 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) + 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 { 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, + ) + 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. + 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 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) { + const crashCause = + step.kind === `crash` + ? `${label} crashed` + : step.kind === `retired-cleanup-rejects` && source.retiredCleanup + ? `${label} 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 = crashCause + } + } else if (isReload(step.kind)) { + if (source.cleaning || source.gate === `crashed`) + return { outcome: `throws`, settles: false } + restartSource(source) + } + 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(counter, source, step) + 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 + } + if (step.kind === `retired-cleanup-rejects`) source.retiredCleanup = false + 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, ++counter.serial) + // A commit that throws ends the step before markReady(). + outcome = commitOpen(source) + if (outcome !== `ok`) break + } + outcome = markSourceReady(source) + break + case `mark-ready`: + outcome = markSourceReady(source) + break + case `write`: + writeRow(counter, source, step) + break + case `commit`: + 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, `${label} 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. + 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 reload + // already dropped the retired run's open transaction. + source.open = null + source.gate = `crashed` + source.cause = `${label} 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. + if (stopped) outcome = `throws` + 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.adapter) source.open.truncate = false + } + } + if (outcome === `throws`) break + if (source.gate !== `ready`) { + source.gate = `error` + source.cause = `${label} gave up` + source.reloading = false + } else { + source.reloading = false + source.failure = null + source.stopped = { + message: `${label} gave up`, + serial: ++counter.serial, + } + } + break + case `report`: + if (stopped || source.gate !== `ready`) outcome = `throws` + else { + source.failure = { + message: `${label} problem`, + serial: ++counter.serial, + } + source.failureFromReload = source.reloading + } + break + case `healthy`: + if (stopped || source.gate !== `ready`) outcome = `throws` + else { + source.failure = null + source.failureFromReload = false + } + break + case `crash`: + crashSource(source, `${label} crashed`) + break + } + 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, +): 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. +// 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 + 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 + // 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 } +type ExpectedNode = { + alias: string + kind: `source` | `live-query` | `include` + status: Gate + hasErrors: boolean + failureReason: ExpectedProblem + error: ExpectedProblem + reloading: boolean + sources: Array +} + +// `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 reported = (problem: Problem): ExpectedProblem => + problem + ? { + message: problem.message, + ...(identity ? { serial: problem.serial } : {}), + } + : null + const error = + source.gate === `error` || source.gate === `crashed` + ? { message: source.cause! } + : reported(source.stopped) + const failureReason = error ? null : reported(source.failure) + return { + alias, + kind, + status: source.gate, + hasErrors: error !== null || failureReason !== null, + failureReason, + error, + reloading: source.reloading, + sources: [], + } +} + +function queryNode( + alias: string, + { gate, cause, reloading }: GateModel, + sources: Array, +): ExpectedNode { + const error = + gate === `error` || gate === `crashed` ? { message: cause! } : null + return { + alias, + kind: `live-query`, + status: gate, + hasErrors: error !== null || sources.some((node) => node.hasErrors), + failureReason: null, + error, + reloading, + sources, + } +} + +// `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 ?? null + return [ + ...(own ? [[node.alias, own] as ProblemSummary] : []), + ...node.sources.flatMap(expectedProblems), + ] +} +function problemOf(node: HealthNode): ProblemSummary { + return [ + node.alias, + (node.error ?? node.failureReason)!.message + .replace(cleanupPrefix, ``) + .replace(cleanedUnderQueryMessage, cleanedUnderQuery), + ] +} + +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, [source(`a`, A)]) + case `join`: + return queryNode(`root`, model.query, [source(`a`, A), source(`b`, B)]) + case `self-join`: + return queryNode(`root`, model.query, [ + source(`first`, A), + source(`second`, A), + ]) + case `nested`: + return queryNode(`root`, model.query, [ + queryNode(`inner`, model.inner, [source(`a`, A)]), + source(`b`, B), + ]) + case `include`: + // 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`), + ]) + 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)]), + ]) + } +} + +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- + +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 + // 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, + startSync: false, + gcTime: 0, + sync: { + sync: (next) => { + 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`)) + } + }, + }, + }) + return { + collection, + failNextCleanup: (armed = true) => { + failNextCleanup = armed + }, + holdNextCleanup: (armed = true) => { + holdNextCleanup = armed + }, + rejectRetiredCleanup: () => { + retiredHeld?.reject(new Error(`${name} cleanup failed`)) + retiredHeld = undefined + }, + holdCleanup: () => { + held ??= createDeferred() + }, + settleCleanup: () => { + held?.resolve() + held = undefined + }, + get params(): SyncParams { + if (!params) throw new Error(`source ${name} has not started`) + return params + }, + } +} + +// 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` | `health` | `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 liveQuery(createLiveQueryCollection((q) => q.from({ a: A }))) + case `join`: + return liveQuery( + createLiveQueryCollection((q) => + q.from({ a: A }).join({ b: B }, ({ a, b }) => eq(a.id, b.id)), + ), + ) + case `self-join`: + 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 liveQuery( + createLiveQueryCollection((q) => + q + .from({ inner }) + .join({ b: B }, ({ inner: row, b }) => eq(row.id, b.id)), + ), + inner, + ) + } + case `include`: + // A plain subquery materializes one child Collection per parent row. + return liveQuery( + createLiveQueryCollection((q) => + q.from({ b: B }).select(({ b }) => ({ + id: 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. +// 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: HealthNode): ExpectedNode { + const problem = (info: HealthNode[`error`]): ExpectedProblem => + info + ? { + message: info.message + .replace(cleanupPrefix, ``) + .replace(cleanedUnderQueryMessage, cleanedUnderQuery), + } + : null + return { + alias: node.alias, + kind: node.kind, + status: node.status as Gate, + hasErrors: node.hasErrors, + failureReason: problem(node.failureReason), + error: problem(node.error), + reloading: node.reloading, + 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: HealthNode, + next: HealthNode, + 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, + ), + ) +} + +// 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, + untagged = false, +): void { + const known = new Map(params.collection._state.acceptedSyncedEntries()) + params.write({ + type: known.has(key) ? `update` : `insert`, + value: untagged + ? { id: key, parentId: key } + : { 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 ?? 0]))) +} + +// 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 + } +} + +// 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) + } +} + +// 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, + directSubscribers = true, + reach?: HistoryReach, +): Promise { + const sources = { A: createSource(`A`), B: createSource(`B`) } + const { query, inner, start, size } = buildQuery( + shape, + sources.A.collection, + sources.B.collection, + ) + const model = freshModel() + // 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` && + step.kind !== `cleanup-hold` && + step.kind !== `cleanup-settle`, + ) + } + // 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 = () => {} + // 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 = [] + // 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. + // A generated history may leave them out, so the query is then a source's + // only subscriber. + const mirrors = { A: new Map(), B: new Map() } + 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 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 = () => { + if (!directSubscribers) return + for (const name of shapeSources[shape]) mirrorSource(name) + } + mirror() + 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 listen = () => { + stop() + announced = query.status + const record = (kind: Event[`kind`]) => { + if (kind === `status`) announced = query.status + const tree = query.health + 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() + } + } + const begin = () => { + listen() + start() + void mountEffect() + } + const consoleError = console.error + console.error = () => {} + try { + expect( + query.health, + `${shape}: the query exposes a health tree`, + ).toBeDefined() + begin() + await flushPromises() + 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) + // 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, + retired: + model.sources[step.source].cleaning || + model.sources[step.source].gate === `cleaned-up`, + 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. + const tag = model.serial + events = [] + let thrown: unknown + try { + if (step.kind === `effect`) { + 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) { + // 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`) + thrown = await reload?.catch((e: unknown) => e) + 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() + await inner?.cleanup({ dependents: true }) + await Promise.all([ + sources.A.collection.cleanup(), + sources.B.collection.cleanup(), + ]) + mirror() + begin() + } else if (shapeSources[shape].includes(step.source)) { + const params = sources[step.source].params + 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() + if (params.collection.status === `crashed` || pre!.cleaning) + 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 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, 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( + step.markReady === false ? { markReady: false } : undefined, + ) + } else if (step.kind === `cancel`) { + const canceled = new AbortController() + canceled.abort() + if (!pre!.open) { + params.begin() + writeDiffing(params, 1, tag) + } + 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`) { + params.reportHealthy() + } else if (step.kind === `crash`) { + params.collection._lifecycle.crash( + new Error(`${step.source} crashed`), + ) + } else { + params.reportError(new Error(`${step.source} problem`)) + } + } + } catch (error) { + thrown = error + } + await flushPromises() + + if (expected !== `ok`) { + expect(thrown, `${label}: contract violation throws`).toBeDefined() + if (expected === `bad-transition`) { + expect(thrown, label).toBeInstanceOf( + InvalidCollectionStatusTransitionError, + ) + } + } else { + expect(thrown, `${label}: legal step`).toBeUndefined() + } + 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.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]), + `${label}: ${name} direct subscriber rows`, + ).toEqual(sortedTags(source.rows)) + if (source.gate === `crashed`) continue + expect( + sortedTags( + new Map( + [...collection._state.acceptedSyncedEntries()].map( + ([key, row]) => [key, row.tag ?? 0], + ), + ), + ), + `${label}: ${name} baseline`, + ).toEqual(sortedTags(expectedBaseline(source))) + } + if (model.query.gate === `ready`) { + 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.errors, + ) + 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.health + expect(project(next), `${label}: health 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 + // what changed. + 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 `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`) + } + } + // A query a cleanup ended announces nothing more; a read still + // shows the current tree. + if ( + !model.queryEnded && + !model.queryDetached && + JSON.stringify(before) !== JSON.stringify(after) + ) { + expect( + events.at(-1)?.tree, + `${label}: last event is current`, + ).toEqual(tree) + } + } + } + } finally { + console.error = consoleError + stop() + for (const subscription of Object.values(mirrorSubscriptions)) + subscription.unsubscribe() + await settleCleanups() + await effect?.dispose() + await query.cleanup() + await inner?.cleanup({ dependents: true }) + await Promise.all([ + sources.A.collection.cleanup(), + sources.B.collection.cleanup(), + ]) + } +} + +// --------------------------------------------------------------------------- +// Histories +// --------------------------------------------------------------------------- + +const shapes: ReadonlyArray = [ + `direct`, + `join`, + `self-join`, + `nested`, + `include`, + `pooled-nested`, +] +const on = (kind: SourceStepKind, source: SourceName): Step => ({ + kind, + source, +}) +const restart: Step = { kind: `restart` } + +// Named histories, one per distinction the law draws. +const namedHistories: Record> = { + "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`), + 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` }, + { 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`, markReady: false }, + { 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 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 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`), + 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`), + 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`), + { 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`), + 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`), + { 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`), + on(`report`, `A`), + on(`healthy`, `A`), + ], + 'fail after Collection readiness keeps the rows, and later reports throw': [ + on(`ready`, `A`), + on(`ready`, `B`), + on(`report`, `A`), + on(`fail`, `A`), + on(`report`, `A`), + on(`healthy`, `A`), + restart, + 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`), + ], + "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`), + ], + '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`), + on(`ready`, `B`), + ], + 'a restart recovers a sync run that failed': [ + on(`fail`, `A`), + restart, + on(`ready`, `A`), + on(`ready`, `B`), + ], + '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 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`), + 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: 2, + arbitrary: fc + .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, untagged]): Step => + untagged + ? { kind: `write`, source, key, untagged } + : { kind: `write`, source, key }, + ), + }, + { + weight: 1, + arbitrary: fc + .tuple( + fc.constantFrom(`commit` as const, `truncate` as const), + fc.constantFrom(`A` as const, `B` as const), + fc.boolean(), + ) + .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) }, + { 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 + .tuple( + fc.constantFrom( + `ready` as const, + `fail` as const, + `report` as const, + `healthy` as const, + `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), + ) + .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> = [ + // 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 + .tuple( + fc.option(fc.constantFrom(...reachPrefixes), { freq: 3 }), + fc.array(stepArb, { maxLength: 12 }), + ) + .map(([prefix, rest]): Array => [...(prefix ?? []), ...rest]), + fc.boolean(), + ], + oraclePropertyOptions(60, `source-health.history`), + )( + `generated histories refine the model`, + (shape, steps, directSubscribers) => { + historiesRan = true + historyReach.beginHistory() + return withDeadline( + runHistory(shape, steps, directSubscribers, historyReach), + ) + }, + 120_000, + ) + afterAll(() => { + if (historiesRan) historyReach.assertReached() + }) +}) + +// --------------------------------------------------------------------------- +// Request lane +// --------------------------------------------------------------------------- + +/** + * 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 + * 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 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`, () => { + // 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>> = [] + 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 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.health.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`) + } 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.health.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 + // 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() + }) + + // 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) + }) + + // 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 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)( + `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 + if (problem === `first-load-error`) + next.fail(new Error(`first load failed`)) + else next.markReady() + return () => adapterCleanup.promise + }, + }, + }) + await collection.preload().catch(() => undefined) + if (problem === `report`) params.reportError(new Error(`stalled`)) + else if (problem === `fail`) params.fail(new Error(`stopped`)) + else if (problem === `crash`) + collection._lifecycle.crash(new Error(`crashed`)) + const announced: Array = [] + 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` + expect(collection.status).toBe( + problem === `crash` + ? `crashed` + : problem === `first-load-error` + ? `error` + : `ready`, + ) + if (keepsCause) { + expect(collection.health.error?.message).toBe( + problem === `crash` ? `crashed` : `first load failed`, + ) + expect(announced).toEqual([]) + } else { + 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.health.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. + 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.health.reloading).toBe(false) + await collection.cleanup() + }, + ) + + // 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 + // 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.health.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 () => { + 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.health.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() + 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 + 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, + 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` + if (outcome === `hold`) { + const load = createDeferred() + held.push(load) + return load.promise + } + 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, + held, + writeParent, + markParentsReady, + } + } + + async function withHistory( + run: (history: ReturnType) => Promise, + ) { + const history = createRequestHistory() + expect( + history.query.health, + `the query exposes a health tree`, + ).toBeDefined() + 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 initial-query readiness 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.health.hasErrors).toBe(false) + + outcomes.push(`fail`) + writeParent(`insert`, 2) + await flushPromises() + expect(query.status).toBe(`ready`) + expect(query.health.error).toMatchObject({ + message: `child load failed`, + }) + 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.health.hasErrors).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.health.error).toMatchObject({ message: `child load failed` }) + + writeParent(`delete`, 1) + writeParent(`delete`, 2) + await flushPromises() + expect(query.health.hasErrors).toBe(false) + 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.health.error).toMatchObject({ + message: `child load failed`, + }) + + await (reloaded === `source` ? children : query).reload() + await flushPromises() + expect(query.health.hasErrors).toBe(false) + expect(query.status).toBe(`ready`) + expect(query.size).toBe(2) + }, + ), + ) + + // 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.health.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`) + const preload = query.preload() + void preload.catch(() => undefined) + writeParent(`insert`, 1) + markParentsReady() + await flushPromises() + expect(query.status).toBe(`error`) + expect(query.health.error).toMatchObject({ message: `child load failed` }) + await expect(preload).rejects.toBeDefined() + })) +}) + +// 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.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.health.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 +// 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.health.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() + } + }) + + // 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.health.reloading).toBe(false) + if (outcome === `ok`) expect(query.size).toBe(1) + if (outcome === `load-fails`) + expect(query.health.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 +// 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.health.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.health.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 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({ + id: `health-root-${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(`root load failed`)), + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.id) + .limit(2), + ) + expect(query.health, `the query exposes a health tree`).toBeDefined() + const consoleError = console.error + console.error = () => {} + try { + await query.preload() + expect(query.health.hasErrors).toBe(false) + + outcomes.push(`fail`) + await Promise.resolve( + query.utils.setWindow({ offset: 4, limit: 2 }), + ).catch(() => undefined) + await flushPromises() + expect(query.status).toBe(`ready`) + 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.health.hasErrors).toBe(false) + } finally { + console.error = consoleError + await query.cleanup() + await source.cleanup() + } + }) +}) + +// Two root loads of one source can overlap: a window move, then a refill +// 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 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) + let shown: string | null = null + return order.map((load) => { + if (outcomes[load] === `fail`) shown = `load ${load} failed` + 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.health.error?.message ?? null, + `after load ${load} settles`, + ).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.health.error?.message ?? null, + `after a later load succeeds`, + ).toBeNull() + } + } finally { + console.error = consoleError + await query.cleanup() + await source.cleanup() + } + }, + ) +}) + +// --------------------------------------------------------------------------- +// 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.health.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.health.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.health).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.health)).toEqual([]) + } + } finally { + console.error = consoleError + await query.cleanup() + await lazy.cleanup() + await eager.cleanup() + } + }) +}) + +// --------------------------------------------------------------------------- +// 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 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. +// `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. +// `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 + 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` + +type Demand = { + held: Set + // Held keys whose latest load failed after readiness, with that load's + // serial. + failed: Map + // 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 + // Held keys a replay failed for when it was judged, with the serial of the + // failed load. + replayFailedKeys: Map + // Replay loads that failed in the current attempt, by serial; the replay + // judges them when it settles. + replayFailedLoads: Map> + // The keys that loads of the open replay loaded, in any attempt, with the + // serial of each key's latest such load. + replayLoadedKeys: Map + // 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> + // Every load the query requested and did not abort, by serial. + requested: Map> + // 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 + // 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 + // 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. +type OnDemandModel = { + gate: QueryGate + demand: Demand + source: SourceModel + serial: number +} + +// The window grammar's one logical demand. +const windowDemand = 0 + +function freshOnDemandModel(held: Iterable): 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(), + 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, + } +} + +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) + } + 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), + ) + // 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 + // 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. + // 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.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 + // 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) + 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.replayLoadedKeys = new Map() + demand.pending = + cause === `truncate` ? demand.pending.filter((load) => load.superseded) : [] + if (cause === `truncate`) demand.requested = new Map() + demand.replayAtFirstCommit = false + 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) + else { + 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. + 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 +// requests, so they belong to its replay. +function reportRequests( + model: OnDemandModel, + requests: ReadonlyArray<{ + serial: number + keys: Set + thrown: boolean + }>, + aborted: ReadonlySet, + 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) => { + demand.lastSerial = Math.max(demand.lastSerial, request.serial) + demand.parks = 0 + 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; a parked acquisition covers its keys without one. +function expectHeldDemandCovered( + model: OnDemandModel, + served: Iterable>, + errorShown: boolean, + label: string, +): void { + if (model.gate !== `ready`) return + const { demand } = model + 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 uncovered = [...demand.held].filter( + (key) => + !live.has(key) && + !isLoaded(model, key) && + !judged.has(key) && + !(errorShown && (demand.failed.has(key) || demand.replayFailed)), + ) + 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 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 + * 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. The source marks ready inside sync(), so a reload completes at + * once: its first commit has no rows, whatever the adapter writes later. + * + * 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 WindowStep = + | `window` + | `settle-ok` + | `settle-fail` + | `reload` + | `report` + | `healthy` + | `fail` + | `crash` + +// 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 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 + replayedOffset: number +} + +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 (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`: + case `settle-fail`: + return + 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 + } + 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. +// 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, + boundary: { owns: (options: LoadSubsetOptions) => boolean }, +) { + const rows = new Map() + 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(), + } +} + +// 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, + directSubscriber = false, + reach?: DemandReach, +) { + const model: WindowModel = { + ...freshOnDemandModel([windowDemand]), + offset: 0, + shown: 0, + moveWaitsForReplay: false, + replayedOffset: 0, + } + // 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> + }> = [] + const requests: Array<{ + serial: number + keys: Set + thrown: boolean + }> = [] + const aborted = new Set() + // The sync run each load belongs to; a retired run's load commits nothing. + const loadRuns = new Map() + // Each failed load's message, by serial. + const messages = new Map() + let serials = 0 + let run = 0 + let failures = 0 + let params!: SyncParams + const boundary = { owns: (_options: LoadSubsetOptions) => false } + 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 + const thisRun = ++run + // Rows this sync run has served; a reload's run serves them again. + const served = new Set() + next.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + if (boundary.owns(options)) return true + const serial = ++serials + const deferred = createDeferred() + 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 deferred.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() + }) + }, + } + }, + }, + }) + 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 }) + .orderBy(({ row }) => row.id) + .limit(2), + ) + const consoleError = console.error + console.error = () => {} + let watcher: ReturnType | undefined + try { + void query.preload().catch(() => undefined) + await flushPromises() + if (directSubscriber) watcher = watchDirectly(source, boundary) + report(false) + deriveDemandGate(model) + for (const [index, step] of steps.entries()) { + const label = `step ${index} ${step}` + applyWindowStep(model, step) + if (model.moveWaitsForReplay) + reach?.reach(`window move waits for a replay`) + if (directSubscriber && model.demand.replayActive) + reach?.reach(`direct subscriber during a replay`) + let settled = 0 + 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++) { + settled += settle(true) + await flushPromises() + report(false) + } + } else if (step === `settle-fail`) { + settled = settle(false) + } else if (step === `reload`) { + void source.reload().catch(() => undefined) + } else if (step === `crash`) { + source._lifecycle.crash(new Error(`source crashed`)) + } else { + try { + 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 predicts that. + } + } + await flushPromises() + 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(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.gate !== `crashed`) { + expect( + sourceNode.failureReason?.message ?? null, + `${label}: source failureReason`, + ).toBe( + model.source.stopped ? null : (model.source.failure?.message ?? null), + ) + expect( + sourceNode.error?.message ?? null, + `${label}: source error`, + ).toBe(model.source.stopped?.message ?? null) + } + if (model.gate === `ready` && loads.length === 0 && shownError === null) { + expect( + query.toArray.map((row) => row.id), + `${label}: window rows`, + ).toEqual([model.shown + 1, model.shown + 2]) + } + } + } finally { + console.error = consoleError + watcher?.stop() + for (const { deferred } of loads.splice(0)) deferred.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 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`, + `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`, + `settle-fail`, + `window`, + `reload`, + `settle-ok`, + ], + "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`, + ) + 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> = [ + [`settle-ok`, `reload`, `window`], + [`settle-ok`, `reload`, `settle-fail`, `window`], + ] + fcTest.prop( + [ + fc + .tuple( + fc.option(fc.constantFrom(...demandReachPrefixes), { freq: 2 }), + fc.array(step, { maxLength: 10 }), + ) + .map(([prefix, rest]): Array => [ + ...(prefix ?? []), + ...rest, + ]), + fc.boolean(), + ], + oraclePropertyOptions(60, `source-health.on-demand`), + )( + `generated demand histories refine the model`, + (steps, directSubscriber) => { + demandHistoriesRan = true + demandReach.beginHistory() + return withDeadline( + runDemandHistory(steps, directSubscriber, demandReach), + ) + }, + 120_000, + ) +}) + +// --------------------------------------------------------------------------- +// 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: 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; + * `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 +// the next load the query requests throw synchronously. +type LazyStep = + | { kind: `parent` | `unparent`; key: RowKey } + | { + kind: + | `settle-ok` + | `settle-fail` + | `settle-first-ok` + | `settle-first-fail` + | `settle-last-ok` + | `settle-last-fail` + | `throw-next` + | `reload` + | `truncate` + | `child-ready` + | `report` + | `fail` + | `crash` + } + +// 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 +} + +// 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 + const failedFirstLoad = source.gate === `error` + switch (step.kind) { + case `parent`: + 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`: + case `settle-first-ok`: + case `settle-first-fail`: + case `settle-last-ok`: + case `settle-last-fail`: + case `throw-next`: + return + 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, 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`: + commitOnDemand(model, () => + applySourceStep(model, source, `source`, { kind: `mark-ready` }), + ) + break + 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) +} + +// 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`, + `coverage checked during an open replay`, + `parked demand during a replay`, +] 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 +// 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, + { + asyncChildReady = false, + honorsAbort = true, + holdChildReady = false, + directSubscriber = false, + withEffect = false, + reach, + }: { + asyncChildReady?: boolean + honorsAbort?: boolean + holdChildReady?: boolean + directSubscriber?: boolean + withEffect?: boolean + reach?: LazyReach + } = {}, +) { + 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. + const loads: Array<{ + serial: number + deferred: ReturnType> + }> = [] + const requests: Array<{ + serial: number + keys: Set + thrown: boolean + }> = [] + const aborted = new Set() + // Adapter loads not yet settled or aborted: what the adapter still serves. + const live = new Map>() + // The keys and the child sync run of every request; a retired run's load + // commits nothing. + const loadKeys = new Map>() + const loadRuns = new Map() + let run = 0 + let serials = 0 + let throwNext = false + let failures = 0 + let childRuns = 0 + let parentParams!: SyncParams + 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() + 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 + 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() + forgetServed = () => served.clear() + if (holdChildReady) heldReady = () => params.markReady() + else if (childRuns++ > 0 && asyncChildReady) + setTimeout(() => params.markReady(), 0) + 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 + 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() + 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.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`)) + }) + return deferred.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 truncate or + // a source reload in this step made them. + const report = (replayMade = false) => { + 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() + } + // 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, + ) => { + for (const { serial, deferred } of picked) { + loads.splice( + loads.findIndex((load) => load.serial === serial), + 1, + ) + 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`)) + } + } + let watcher: ReturnType | 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 | 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()) { + 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.health + 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` + // 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.demand.replayActive + if (step.kind === `reload` && model.gate === `loading`) + reach?.reach(`reload during the first load`) + if (step.kind === `truncate` && model.source.reloading) + reach?.reach(`truncate while a reload is in flight`) + 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({ + 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++) { + settle([...loads], true) + await flushPromises() + report() + } + } else if (step.kind === `settle-fail`) { + // Each failure has its own message, so two problems never look alike. + 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 === `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`) { + void children.reload().catch(() => undefined) + } else if (step.kind === `truncate`) { + if (truncates) { + childParams.begin() + // Readiness stays with markReady(), so a truncate can land while + // a reload is still in flight. + childParams.truncate({ markReady: false }) + childParams.commit() + forgetServed() + } + } 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() + 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 ( + truncates && + !truncateIsReloadCommit && + (model.gate === `ready` || model.gate === `loading`) + ) + expect( + [...model.demand.held].filter( + (key) => + !thrownLoads.has(model.demand.failed.get(key) ?? 0) && + stepRequests.every((request) => !request.keys.has(key)), + ), + `${label}: keys the truncate replay did not request`, + ).toEqual([]) + deriveDemandGate(model) + + expect(query.status, `${label}: query status`).toBe(model.gate) + watcher?.check(label) + const { demand } = model + if (watcher && demand.replayActive) + reach?.reach(`direct subscriber during a replay`) + if (demand.replayFailedKeys.size > 0 || demand.replayFailed) + reach?.reach(`replay load fails`) + if (demand.pending.some((load) => load.superseded)) + reach?.reach(`superseded replay load`) + if (model.gate === `ready` && expectedRequestError(demand) !== null) + reach?.reach(`request error shown`) + expect(query.health, `${label}: stable read`).toBe(query.health) + if ( + demandOnly && + !replayBefore && + JSON.stringify(project(query.health)) === factsBefore + ) { + expect(query.health, `${label}: unchanged tree keeps identity`).toBe( + treeBefore, + ) + expect( + announcements, + `${label}: unchanged tree announces nothing`, + ).toBe(announcedBefore) + } + 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( + 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` && + demand.pending.length === 0 && + !demand.replayFailed && + !demand.replayActive + ) { + 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( + [...demand.held] + .sort((a, b) => a - b) + .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) + reach?.reach(`effect compared at a quiet checkpoint`) + if (effect && !effect.disposed) + 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() + 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, + `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, + `truncate` as const, + `child-ready` 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 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 }, + { 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` }, + ], + "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 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` }, + { 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)) + 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( + [ + { 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(`a demand change that asks for a failed key again keeps its replay open`, () => + 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 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 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 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( + [ + { 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 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 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 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( + [ + { 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, + })) + // 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 }, + )) + // 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, + ) + 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 + 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: `fail` }, { kind: `truncate` }], + holdChildReady: true, + }, + { + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + ], + }, + ] + fcTest.prop( + [ + 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(), + fc.boolean(), + fc.boolean(), + ], + oraclePropertyOptions(60, `source-health.lazy-demand`), + )( + `generated lazy demand histories refine the model`, + ( + { prefix, rest }, + asyncChildReady, + generatedHonorsAbort, + generatedHoldChildReady, + directSubscriber, + withEffect, + ) => { + 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, + ) +}) + +// --------------------------------------------------------------------------- +// Effect lane +// --------------------------------------------------------------------------- + +/** + * 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. 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 + * `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 + * on-demand source still has its first load pending. After it, the + * 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 + * `onHealthChange` once: no two reported trees are the same object. + */ +describe(`effect errors`, () => { + type EffectHistory = { + skipInitial: boolean + syncMode: `eager` | `on-demand` + failure: + | `before gate` + | `fail after gate` + | `crash after gate` + | `report after gate` + } + const histories: Array = [true, false].flatMap((skipInitial) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + ( + [ + `before gate`, + `fail after gate`, + `crash after gate`, + `report after gate`, + ] as const + ).map((failure) => ({ skipInitial, syncMode, failure })), + ), + ) + + 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() + } + }), + } + }, + }, + }) + 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: (health) => trees.push(health), + }) + const consoleError = console.error + console.error = () => {} + try { + await flushPromises() + if (failure === `before gate`) { + params.fail(new Error(`gave up`)) + await flushPromises() + // 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 === `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`, + failureReason: { message: `stalled` }, + error: null, + }, + ], + }) + 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 { + params.fail(new Error(`gave up`)) + } + await flushPromises() + // 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) + if (!ends) { + expect(trees.at(-1)).toMatchObject({ + kind: `live-query`, + hasErrors: true, + sources: [ + { + alias: `row`, + status: syncMode === `on-demand` ? `error` : `ready`, + failureReason: null, + error: { message: `gave up` }, + }, + ], + }) + } + } finally { + console.error = consoleError + await effect.dispose() + await source.cleanup() + } + }) +}) + +// 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 }) => { + 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 + console.error = () => {} + const effect = createEffect({ + query: (q) => q.from({ row: source }), + skipInitial, + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + onHealthChange: (health) => trees.push(health), + }) + try { + await flushPromises() + 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() + 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 + * 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 load failures`, () => { + 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: (health) => trees.push(health), + }) + const consoleError = console.error + console.error = () => {} + try { + await flushPromises() + writeParent(1) + if (parentMode === `eager`) parentParams.markReady() + await flushPromises() + + // Model: after initial-query readiness, a failed load ends the Effect. + outcomes.push(`fail`) + writeParent(2) + await flushPromises() + 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() + await parents.cleanup() + await children.cleanup() + } + }) +}) + +/** + * 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 `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 +// 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.health + expect(first.sources.map((node) => node.alias)).toEqual([alias]) + expect(view.health, `a second read gives the same tree`).toBe(first) + params.reportError(new Error(`stalled`)) + const reported = view.health + expect(reported).not.toBe(first) + expect(view.health, `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) => { + 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, type: `insert` | `delete` = `insert`) => { + params.begin() + params.write({ type, 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.health.sources.slice(1) + expect(includes().map((node) => [node.alias, node.kind])).toEqual([ + [`a`, `include`], + [`a`, `include`], + ]) + + let events = 0 + 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.health + B.write({ id: 3, parentId: 3 }) + B.write({ id: 4, parentId: 4 }) + await flushPromises() + stop() + 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. + await query.cleanup() + expect(includes().map((node) => node.kind)).toEqual([ + `include`, + `include`, + ]) + } finally { + console.error = consoleError + await query.cleanup() + await A.collection.cleanup() + await B.collection.cleanup() + } + }) +}) + +// --------------------------------------------------------------------------- +// 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/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/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index efaa576aef..cc774f9745 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.fail(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() diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index bb5f8b6048..dc449153b6 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1757,12 +1757,27 @@ function createElectricSync>( begin, write, commit: commitSyncTransaction, - markReady, - markError, + 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 + 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 +1954,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 (sessionCleanedUp || ready || stopped) return if (failure !== undefined) { initialSyncFailedFinally = true - markError(failure) + giveUp(failure) } else if (isActiveLifecycle()) { wrappedMarkReady(isBuffering, streamErrorVersion) } else markReady() @@ -1953,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)) } @@ -1998,9 +2013,9 @@ 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) + if (!ready) failInitialSync(cause) } if (shapeOptions.onError) { @@ -2008,14 +2023,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) + const initialSyncFailed = !ready + failWaits(errorParams) console.error( `An error occurred while syncing collection: ${collection.id}, \n` + (initialSyncFailed @@ -2319,7 +2334,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - markError( + giveUp( new Error( `Electric resume state referenced an unseen row; a full snapshot is required`, ), @@ -2625,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) @@ -2653,7 +2667,7 @@ function createElectricSync>( streamErrorVersion++ unsubscribeStream() abortController.abort() - if (collection.status !== `error`) markError(error) + giveUp(error) }, ) } @@ -2708,15 +2722,12 @@ 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 (!ready) failInitialSync(error) + else giveUp(error) + } }, ) @@ -2740,7 +2751,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 fa62022c93..03fd18ecea 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()]) @@ -1849,6 +1849,19 @@ type ProcessCommand = | { kind: `cleanup`; slot: ProcessSlot } | { kind: `restart`; slot: ProcessSlot } +/** + * After Collection readiness, an Electric sync adapter that gives up reports a + * 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.failureReason).toBeNull() + expect(collection.health.error).toBeTruthy() +} + type ProcessRuntime = { collection: Collection subscriber?: (messages: Array>) => void @@ -1858,7 +1871,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 the Collection's `error`, not status `error`. + terminalAfterReadiness: boolean active: boolean retired: boolean preloads: Array @@ -2148,9 +2166,9 @@ async function runPersistenceFailureCrashOnlyWitness( publicationsBeforeRetiredWork, ), }).toEqual({ - statusAtAdmissionCut: `error`, + statusAtAdmissionCut: `crashed`, publicErrorNameAtAdmissionCut: `PersistedCollectionDurabilityError`, - status: `error`, + status: `crashed`, publicErrorName: `PersistedCollectionDurabilityError`, hasNamedPersistenceSemantics: true, preservesCause: true, @@ -2415,7 +2433,9 @@ async function runProcessGrammar( resumeAvailable: false, requiresCompleteResume: false, resettingSnapshot: false, + reachedReadiness: false, terminalError: false, + terminalAfterReadiness: false, active: false, retired: false, preloads: [], @@ -2437,7 +2457,9 @@ async function runProcessGrammar( runtime.subscriber = subscriber runtime.requiresCompleteResume = runtime.resumeAvailable runtime.resettingSnapshot = false + runtime.reachedReadiness = false runtime.terminalError = false + runtime.terminalAfterReadiness = false runtime.active = true runtime.retired = false } @@ -2449,7 +2471,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,7 +2550,9 @@ async function runProcessGrammar( runtime.resumeAvailable = false runtime.requiresCompleteResume = false runtime.resettingSnapshot = false + runtime.reachedReadiness = false runtime.terminalError = false + runtime.terminalAfterReadiness = false return `cleaned up` } if (command.kind === `retired-callback`) { @@ -2566,6 +2594,7 @@ async function runProcessGrammar( applyReferenceBatch(runtime.reference, messages) runtime.resettingSnapshot = false runtime.resumeAvailable = true + runtime.reachedReadiness = true return `delivered: snapshot` } @@ -2595,6 +2624,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 +2632,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 +4243,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 +4337,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` }), ) 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/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/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`, } } 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 3da2a4d0ca..eb3b37645b 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -325,7 +325,22 @@ function createPowerSyncCollectionConfig< */ const sync: SyncConfig = { sync: (params) => { - const { begin, write, collection, commit, markReady, markError } = 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: @@ -625,9 +640,7 @@ function createPowerSyncCollectionConfig< message: `Could not start syncing process for ${viewName} into ${trackedTableName}`, error, }) - if (collection.status === `loading`) { - markError(error) - } + if (!ready) fail(error) }) const invokeOnUnload = (): void | Promise => { @@ -733,6 +746,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 @@ -747,12 +762,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 @@ -808,9 +822,12 @@ 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`) markReady() + // Replacing the trigger alone does not restore change tracking: + // its baseline writes must also apply before sync works again. + if (trackingOutage) { + trackingOutage = false + reportHealthy() + } } } } @@ -821,9 +838,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) markError(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 failureReason, not fail(). + if (stopped) { + // Cleanup owns the outcome. + } else if (ready) { + trackingOutage = true + reportError(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 957471b67a..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() @@ -1194,7 +1229,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 @@ -1564,7 +1599,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 } @@ -1577,7 +1615,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 +1640,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 6c3926542f..f471be1972 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.` @@ -619,7 +620,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 +2698,9 @@ 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(), } as never) const loadSubset = @@ -3246,7 +3249,13 @@ 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 a failure reason. Status stays ready; data stays available. + expect(collection.status).toBe(`ready`) + expect(collection.health.hasErrors).toBe(true) + expect(collection.health.failureReason?.message).toMatch( + `trigger installation failed`, + ) } finally { await collection.cleanup() } @@ -3276,7 +3285,14 @@ 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 failure reason. + await vi.waitFor(() => + expect(collection.health.failureReason).toMatchObject({ + 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`) { @@ -3290,7 +3306,11 @@ 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.hasErrors).toBe(false), + ) + expect(collection.status).toBe(`ready`) expect([...collection.keys()]).toEqual( change === `unchanged` ? [`retained`] : [], ) 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/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 3feb0b5ac9..01b0db642c 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -302,7 +302,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`. @@ -321,16 +322,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 `health` tree. /** Check if query is currently fetching (initial or background) */ isFetching: boolean /** Check if query is refetching in background (not initial fetch) */ @@ -345,24 +338,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> @@ -427,23 +408,6 @@ class QueryCollectionUtilsImpl implements QueryCollectionUtils { this.writeBatch = writeUtils.writeBatch } - public async clearError() { - await this.refetch({ throwOnError: true }) - } - - // Getters for error state - 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 @@ -831,8 +795,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> @@ -1157,12 +1119,35 @@ export function queryCollectionOptions( write, commit, markReady, - markError, + fail, + reportError, + reportHealthy, invalidateLoadedSubsets, collection, 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 @@ -1290,8 +1275,32 @@ 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 + // observed Query is failing. + // In failure order, so the latest failure still failing is last. + const failingQueries = new Map() + const reportFailing = (hashedQueryKey: string, error: unknown) => { + failingQueries.delete(hashedQueryKey) + failingQueries.set(hashedQueryKey, error) + if (runReady) reportError(error) + } + const reportRecovered = (hashedQueryKey: string) => { + 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, ExceptionalResultSettlement @@ -2801,7 +2810,11 @@ export function queryCollectionOptions( if (unownedDirectWritePositions.get(key) === position) unownedDirectWritePositions.delete(key) }) - if (!signal?.aborted) markReady() + if (!signal?.aborted) { + // A later successful refetch does not revive a failed initial + // load; only a reload does. + readyRun() + } } const accepted = whenSyncAccepted(applied) if (accepted === true) finishApplication() @@ -2886,25 +2899,23 @@ export function queryCollectionOptions( if (finish()) { failedResultApplications.delete(hashedQueryKey) // Applying cached data during a fetch does not establish recovery. - if (!isFetching && applicationErrorRevision === errorRevision) { - state.lastError = undefined - state.errorCount = 0 + if ( + !isFetching && + applicationErrorRevision === errorRevisionOf(hashedQueryKey) + ) { + reportRecovered(hashedQueryKey) } } }, (error) => { if (!finish()) return failedResultApplications.set(hashedQueryKey, error) - state.lastError = error - state.errorCount++ - errorRevision++ + bumpErrorRevision(hashedQueryKey) console.error( `[QueryCollection] Error applying query ${String(hashToQueryKey.get(hashedQueryKey))}:`, error, ) - if (collection.status === `loading`) { - markError(error) - } + if (!failBeforeReady(error)) reportFailing(hashedQueryKey, error) }, ) } @@ -2918,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) => { @@ -2943,9 +2954,11 @@ export function queryCollectionOptions( ) resultApplicationControllers.delete(hashedQueryKey) failedResultApplications.delete(hashedQueryKey) - if (!result.isFetching && currentErrorRevision === errorRevision) { - state.lastError = undefined - state.errorCount = 0 + if ( + !result.isFetching && + currentErrorRevision === errorRevisionOf(hashedQueryKey) + ) { + reportRecovered(hashedQueryKey) } } return @@ -3262,9 +3275,7 @@ export function queryCollectionOptions( if (observedQuery) { observedErrorUpdates.set(observedQuery, result.errorUpdateCount) } - state.lastError = result.error - state.errorCount++ - errorRevision++ + bumpErrorRevision(hashedQueryKey) } console.error( @@ -3274,10 +3285,11 @@ 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. - if (collection.status === `loading`) { - markError(result.error) - } + // snapshot available and report a failureReason. + // A re-notified error is the same failure, so it is not reported + // again. + if (!failBeforeReady(result.error) && isNewError) + reportFailing(hashedQueryKey, result.error) } } return handleQueryResult @@ -3384,15 +3396,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) } } @@ -3493,9 +3501,11 @@ export function queryCollectionOptions( else void applied.then(retireOwnership, () => undefined) state.observers.delete(hashedQueryKey) + reportRecovered(hashedQueryKey) if (query?.state.fetchStatus !== `fetching`) fetchStartGenerations.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) + errorRevisions.delete(hashedQueryKey) queryRefCounts.delete(hashedQueryKey) effectivePersistedGcTimes.delete(hashedQueryKey) } @@ -3562,11 +3572,13 @@ 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`) fetchStartGenerations.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) + errorRevisions.delete(hashedQueryKey) queryRefCounts.set(hashedQueryKey, 0) return } 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..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 @@ -14,6 +14,23 @@ 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 failed first load while it is + * in `error`, or the cause of its failure reason after readiness. + */ +function reportedError(collection: { + status: string + health: { + failureReason: { cause: unknown } | null + error: { cause: unknown } | null + } + _lifecycle: { getSyncError: () => unknown } +}): unknown { + return collection.status === `error` + ? collection._lifecycle.getSyncError() + : (collection.health.failureReason ?? collection.health.error)?.cause +} + /** * # Does Query-backed loadSubset preserve identity and lifecycle? * @@ -980,7 +997,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) @@ -1158,6 +1175,128 @@ async function expectForeignOwnerReleaseKeepsInFlightSignal(): Promise { } } +/** + * 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. + */ +async function expectRefetchFailureIsHealth(): 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, + failureReason: collection.health.failureReason?.cause ?? null, + error: collection.health.error?.cause ?? null, + rows: collection.toArray.map((row) => row.id), + }) + try { + await collection.preload() + expect(observe()).toEqual({ + status: `ready`, + failureReason: null, + error: null, + rows: [`first`], + }) + + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.health.hasErrors).toBe(true)) + expect(observe()).toEqual({ + status: `ready`, + failureReason: error, + error: null, + rows: [`first`], + }) + + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.health.hasErrors).toBe(false)) + expect(observe()).toEqual({ + status: `ready`, + failureReason: null, + error: null, + rows: [`second`], + }) + } finally { + loggedError.mockRestore() + await collection.cleanup() + queryClient.clear() + } +} + +/** + * 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 { + 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.hasErrors).toBe(false) + + failing.add(1) + await collection.utils.refetch() + 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.health.failureReason?.message).toBe(`subset 1 failed`) + + failing.clear() + await collection.utils.refetch() + await vi.waitFor(() => expect(collection.health.hasErrors).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() @@ -1167,6 +1306,7 @@ async function expectInitialQueryFailureStatus(): Promise { .fn() .mockRejectedValueOnce(error) .mockResolvedValueOnce([{ id: `recovered` }]) + .mockResolvedValueOnce([{ id: `recovered` }]) const collection = createCollection( queryCollectionOptions({ id, @@ -1188,8 +1328,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 { @@ -1203,10 +1343,20 @@ async function expectInitialQueryFailureStatus(): Promise { throw new TraceAssertionError(0, caught) } - 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.status).toBe(`ready`) expect(collection.get(`recovered`)).toBeDefined() + }) + expect(reportedError(collection)).toBe(error) + expect(collection.status).toBe(`error`) + 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() }) @@ -1260,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) => { @@ -1273,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 }) @@ -1286,7 +1436,7 @@ async function expectEveryFailedSourceToRecover(): Promise { }) return { collection, - fail: () => fail(), + fail: () => failSync(), recover: (row: Row) => recover(row), } } @@ -1315,13 +1465,24 @@ async function expectEveryFailedSourceToRecover(): Promise { await expect(preload).rejects.toThrow() expect(live.status).toBe(`error`) + // 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` }) + await leftPreload expect(left.collection.status).toBe(`ready`) expect(right.collection.status).toBe(`error`) expect(live.status).toBe(`error`) + 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`) + await live.cleanup() + const livePreload2 = live.preload() + await livePreload2 expect(live.status).toBe(`ready`) expect(live.toArray.map((row) => row.id)).toEqual([`shared`]) } finally { @@ -1361,7 +1522,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`) @@ -1469,7 +1630,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() @@ -2382,7 +2543,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) @@ -2549,7 +2710,15 @@ describe(`loadSubset lifecycle oracle`, () => { await expectReleasedRefetchRejects() }) - it(`reports an initial query failure and recovers after a successful refetch`, async () => { + 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() + }) + + it(`keeps an initial query failure until a restart, even after a successful refetch`, async () => { await expectInitialQueryFailureStatus() }) @@ -2557,7 +2726,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 1923d6701e..c60d302e2d 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -45,6 +45,23 @@ import type { import type { NonSingleResult } from '../../db/src/types.js' import type { QueryCollectionUtils } from '../src/query.js' +/** + * 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: { + failureReason: { cause: unknown } | null + error: { cause: unknown } | null + } + _lifecycle: { getSyncError: () => unknown } +}): unknown { + return collection.status === `error` + ? collection._lifecycle.getSyncError() + : (collection.health.failureReason ?? collection.health.error)?.cause +} + /** * # Who owns a Query-backed row, and when may it disappear? * @@ -2666,7 +2683,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` @@ -2798,7 +2815,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` @@ -2931,7 +2948,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` @@ -3048,7 +3065,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` @@ -3294,7 +3311,7 @@ describe(`query collection ownership lifecycle`, () => { handlerCollection: { utils: Pick< QueryCollectionUtils, - `refetch` | `clearError` + `refetch` > }, transaction: { @@ -3363,7 +3380,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` @@ -3607,12 +3624,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)) }) @@ -4217,7 +4234,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` } @@ -4254,14 +4271,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, @@ -4282,9 +4299,10 @@ describe(`query collection ownership lifecycle`, () => { expectPublicRefetchObservation(expected, `recovered`, await recovery) expect(rejection).toBeUndefined() - expect(collection.status).toBe(`ready`) - expect(collection.utils.lastError).toBeUndefined() - expect(collection.utils.errorCount).toBe(0) + // 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(reportedError(collection)).toBeDefined() expect(collection.get(shared.id)?.name).toBe(recovered.name) }) @@ -4375,21 +4393,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 }), ) @@ -4461,32 +4480,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) @@ -4494,14 +4497,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 @@ -4544,23 +4547,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([]) @@ -4623,11 +4624,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 }), @@ -4637,19 +4638,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([]) @@ -4703,29 +4704,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]) @@ -5047,7 +5047,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({ @@ -5714,7 +5714,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]) }) @@ -5772,7 +5772,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) @@ -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: [] }, }) @@ -7512,7 +7514,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, @@ -10159,9 +10161,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`) }, ) @@ -12314,7 +12315,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({ @@ -12333,8 +12334,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 { @@ -14222,10 +14223,13 @@ 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`) + // 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(`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 f4672f9d86..cc55f14f69 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -43,6 +43,23 @@ import type { } from '@tanstack/db' import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query' +/** + * 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: { + failureReason: { cause: unknown } | null + error: { cause: unknown } | null + } + _lifecycle: { getSyncError: () => unknown } +}): unknown { + return collection.status === `error` + ? collection._lifecycle.getSyncError() + : (collection.health.failureReason ?? collection.health.error)?.cause +} + interface TestItem { id: string name: string @@ -507,7 +524,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() }) @@ -665,7 +682,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, @@ -3317,7 +3334,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({ @@ -5556,7 +5573,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`] @@ -5594,14 +5611,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) @@ -5631,7 +5648,7 @@ describe(`QueryCollection`, () => { ) await vi.waitFor(() => { - expect(collection.utils.isError).toBe(true) + expect(reportedError(collection)).toBeDefined() }) await expect( @@ -5816,8 +5833,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() @@ -5880,38 +5897,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`) @@ -5928,26 +5939,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 () => { @@ -5975,8 +5983,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 @@ -5992,25 +5999,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() }) @@ -6050,19 +6055,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 @@ -6076,24 +6081,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 @@ -6121,16 +6124,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() @@ -6147,9 +6150,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() }) }) @@ -11086,7 +11089,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) @@ -11714,3 +11717,251 @@ 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.health.reloading).toBe(false) + expect(collection.health.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() + } + }, + ) + + // 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.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]) + expect( + (client.getQueryData(queryKey) as Array<{ id: number }>) + .map((row) => row.id) + .sort(), + ).toEqual([1, 3]) + } finally { + consoleError.mockRestore() + await collection.cleanup() + client.clear() + } + }) +}) + +/** + * 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.health.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() + } + }, + ) + + // 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.health.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/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts index 2adf895b7f..ac9345a617 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, oracleReplayRequested, @@ -1989,15 +1989,25 @@ 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) + // 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), + { ...options, seed }, ) // Positive reach witness: generated histories serve continuation pages // before a refetch, which is the premise of the gap. diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 562d9b3d36..a5ce3e69b2 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, + health, // health tree: health.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 `health?.hasErrors` from the health tree to show stale-data warnings. + ### useLiveSuspenseQuery ```tsx diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index ca4e0b8da5..04ba8f90ba 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -38,9 +38,9 @@ import type { CollectionStatus, Context, DbClient, + HealthNode, InferResultType, InitialQueryBuilder, - LiveQueryPersistedStatus, LiveQueryWindowController, NonSingleResult, QueryBuilder, @@ -75,7 +75,6 @@ export type UseLiveInfiniteQueryReturn = Omit< fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean - error: unknown } export type UseLiveInfiniteQueryReturnWithCollection< @@ -89,19 +88,21 @@ export type UseLiveInfiniteQueryReturnWithCollection< status: CollectionStatus isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - 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. 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 `health`. */ + isSyncError: boolean isEnabled: true pages: Array> pageParams: Array fetchNextPage: () => Promise hasNextPage: boolean isFetchingNextPage: boolean - error: unknown + health: HealthNode | undefined } type EnabledLiveQueryReturn = ReturnType< @@ -341,6 +342,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 @@ -456,12 +458,11 @@ 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, - isError: snapshot.isError, + isLoadingError: snapshot.isLoadingError, + isCrashed: snapshot.isCrashed, isCleanedUp: snapshot.isCleanedUp, + isSyncError: snapshot.isSyncError, collection: snapshot.collection as EnabledLiveQueryReturn[`collection`], isEnabled: snapshot.isEnabled, @@ -470,6 +471,6 @@ export function useLiveInfiniteQuery( fetchNextPage, hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, - error: snapshot.error, + health: snapshot.health, } } diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 853d4ea68b..be6f7bed13 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -28,11 +28,11 @@ import type { Context, DbClient, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -323,6 +323,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. 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 `health`. */ + isSyncError: boolean + health: HealthNode | undefined +} + /** * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch @@ -379,12 +394,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 ( *
                            @@ -392,6 +407,7 @@ export function warnUnhashableDerivedIdentity( *
                          * ) */ + // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, @@ -401,16 +417,8 @@ export function useLiveQuery( data: InferResultType collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled if always returns QueryBuilder - isLoading: boolean - isReady: boolean - persistedStatus: LiveQueryPersistedStatus - isPersistedReady: boolean - persistedError: unknown | undefined - isIdle: boolean - isError: boolean - isCleanedUp: boolean isEnabled: true // Always true if always returns QueryBuilder -} +} & UseLiveQueryFlags // Overload 2: Accept query function that can return undefined/null export function useLiveQuery( @@ -423,16 +431,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 -} +} & UseLiveQueryFlags // Overload 3: Accept query function that can return LiveQueryCollectionConfig export function useLiveQuery( @@ -445,16 +445,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 -} +} & UseLiveQueryFlags // Overload 4: Accept query function that can return Collection export function useLiveQuery< @@ -471,16 +463,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 -} +} & UseLiveQueryFlags // Overload 5: Accept query function that can return all types export function useLiveQuery< @@ -507,16 +491,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 -} +} & UseLiveQueryFlags /** * Create a live query using configuration object @@ -543,12 +519,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
                          @@ -561,16 +537,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 -} +} & UseLiveQueryFlags // Overload 7: Accept config object with a query that can return undefined/null export function useLiveQuery( @@ -580,16 +548,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 -} +} & UseLiveQueryFlags // Overload 8: Accept config object with legacy deps export function useLiveQuery( @@ -600,16 +560,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 -} +} & UseLiveQueryFlags // Overload 9: Accept config object with legacy deps and a query that can return undefined/null export function useLiveQuery( @@ -620,16 +572,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 -} +} & UseLiveQueryFlags /** * Subscribe to an existing live query collection @@ -653,10 +597,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 => )}
                          */ @@ -672,16 +616,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 -} +} & UseLiveQueryFlags // Overload 10: Accept pre-created live query collection with singleResult: true export function useLiveQuery< @@ -695,16 +631,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 -} +} & UseLiveQueryFlags // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( @@ -969,10 +897,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( @@ -982,12 +910,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) @@ -1033,7 +969,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 28c39bab8e..cca2b6febb 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -9,6 +9,7 @@ import type { Context, DbClient, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, @@ -155,6 +156,7 @@ export function useLiveSuspenseQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> + health: HealthNode | undefined } // Overload 2: Accept config object @@ -164,6 +166,7 @@ export function useLiveSuspenseQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> + health: HealthNode | undefined } // Overload 3: Accept legacy config object @@ -174,6 +177,7 @@ export function useLiveSuspenseQuery( state: Map> data: InferResultType collection: Collection, string | number, {}> + health: HealthNode | undefined } // Overload 4: Accept pre-created live query collection @@ -187,6 +191,7 @@ export function useLiveSuspenseQuery< state: Map data: Array collection: Collection + health: HealthNode | 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 + health: HealthNode | undefined } // Implementation - uses useLiveQuery internally and adds Suspense logic @@ -242,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) @@ -255,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 @@ -282,27 +284,26 @@ 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 `health` 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) ) { 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.health.error?.cause ?? + new Error(`Collection "${result.collection.id}" failed to load`) + ) } if ( !hasBeenReadyRef.current && (result.isLoading || result.isIdle || - result.isError || - (collectionStatus === `error` && - result.persistedStatus !== `unavailable`)) + result.isLoadingError || + result.isCrashed) ) { if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { const reason = queryInfo.identityError @@ -323,7 +324,7 @@ export function useLiveSuspenseQuery( }) let preload: Promise try { - preload = queryInfo.observer.preloadForInitialRender() + preload = queryInfo.observer.preload() } catch (error) { stopWatchingCleanup() throw error @@ -355,5 +356,6 @@ export function useLiveSuspenseQuery( state: result.state, data: result.data, collection: result.collection, + health: result.health, } } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 19600d4867..c96f492620 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -27,6 +27,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-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 { @@ -103,28 +105,12 @@ function makePrecreated( 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) { @@ -180,13 +166,13 @@ function makeHandle(hook: RenderHookResult) { state: r?.state, status: r?.status, isReady: r?.isReady, - 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, + health: r?.health, }) }, async flush() { @@ -217,6 +203,12 @@ const reactDriver: LiveQueryDriver = { createCollection, `conformance-react-lagging-${sourceSeq++}`, ), + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( + createCollection, + `conformance-react-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, @@ -347,7 +339,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) @@ -357,9 +351,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 a00fd70f2a..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, - error: result.error, + isLoadingError: result.isLoadingError, status: result.status, collection: result.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 }), ) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 1e67758e2f..06b390be31 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.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 6d09e4152f..d2a3f63c02 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,7 @@ describe(`Query Collections`, () => { }) methods.commit() methods.markReady() - failSource = () => methods.markError(new Error(`source sync failed`)) + failSource = () => methods.fail(new Error(`source sync failed`)) }, }, }) @@ -1023,8 +1023,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 health tree shows it. act(() => failSource()) - await waitFor(() => expect(result.current.isError).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`) await act(async () => { await todosQuery.cleanup() @@ -1035,6 +1040,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.health?.hasErrors).toBe(false) } finally { unmount() reports.mockRestore() @@ -2229,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 @@ -2268,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) }) @@ -2306,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-recovery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx index 1a1012a0ab..58ff15ade4 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery-recovery.test.tsx @@ -6,7 +6,6 @@ import { createCollection, createLiveQueryCollection, getStableValueHash, - waitForPersistedInitialRender, } from '@tanstack/db' import { DbProvider } from '../src/DbProvider' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' @@ -70,165 +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, - waitForInitialRender: waitForPersistedInitialRender, - 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 25f535836f..e9872e7d3c 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -13,7 +13,6 @@ import { eq, getStableValueHash, gt, - waitForPersistedInitialRender, } from '@tanstack/db' import { Component, StrictMode, Suspense } from 'react' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' @@ -89,19 +88,6 @@ describe(`useLiveSuspenseQuery`, () => { getKey: (row) => row.id, sync: { sync: () => ({}) }, }) - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - waitForInitialRender: waitForPersistedInitialRender, - 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) @@ -155,19 +141,6 @@ describe(`useLiveSuspenseQuery`, () => { }, }, }) - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - waitForInitialRender: waitForPersistedInitialRender, - getOrStartNetworkDeadline: () => Date.now() + 60_000, - getSnapshot: () => ({ status: `ready` }), - subscribe: () => () => {}, - }, - }, - ) const query = createLiveQueryCollection((q) => q.from({ person: source }).where(({ person }) => gt(person.age, 20)), ) @@ -308,19 +281,6 @@ describe(`useLiveSuspenseQuery`, () => { getKey: (row) => row.id, sync: { sync: () => ({}) }, }) - Object.defineProperty( - source.config, - Symbol.for(`@tanstack/db.persistedReadiness`), - { - value: { - networkTimeoutMs: 60_000, - waitForInitialRender: waitForPersistedInitialRender, - 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(() => {}) @@ -367,121 +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 - markError: (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: (row) => write({ type: `insert`, value: row }), - commit: () => { - commit() - }, - markReady, - markError, - } - 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, - waitForInitialRender: waitForPersistedInitialRender, - 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!.markError(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/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index a5140b2031..1c48eaa955 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() { @@ -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,17 +276,21 @@ export function rxdbCollectionOptions( } } - if (!isCleanedUp()) { + if (!isCleanedUp() && !stopped) { markReady() } } - void start().catch((error: unknown) => { + // Whether this sync run stopped: a reload runs `sync` again while the + // Collection still shows the previous run's status. + let stopped = false + function stop(error: unknown) { stopOngoingFetch() - if (collection.status === `loading`) { - markError(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 70607c3e22..708b773194 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' @@ -142,6 +143,106 @@ 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.health.error?.message).toBe(`reload query failed`) + } finally { + query.mockRestore() + await collection.cleanup() + await db.remove() + } + }, + ) + + // 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.health.error?.message).toBe(streamError.message) + expect(collection.health.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 @@ -241,13 +342,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/skills/solid-db/SKILL.md b/packages/solid-db/skills/solid-db/SKILL.md index 0039c6cc89..4d49fb806a 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.health → health tree; query.health?.hasErrors means data may be stale // query.state → ReactiveMap // query.collection → Collection diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 3430789030..715ba04cc2 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -23,11 +23,11 @@ import type { CollectionStatus, Context, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -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. 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 `health`. */ + isSyncError: boolean + health: HealthNode | undefined +} + /** * Create a live query using a query function * @param queryFn - Query function that defines what data to fetch @@ -84,7 +99,7 @@ type InferConditionalResultType = * *
                          Loading...
                          *
                          - * + * *
                          Error: {todosQuery.status}
                          *
                          * @@ -109,6 +124,7 @@ type InferConditionalResultType = *
                          * ) */ + // Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, @@ -121,15 +137,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 -} +} & UseLiveQueryFlags // Overload 1b: Accept query function that can return undefined/null export function useLiveQuery( @@ -145,15 +153,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 -} +} & UseLiveQueryFlags /** * Create a live query using configuration object @@ -186,7 +186,7 @@ export function useLiveQuery( * *
                          Loading...
                          *
                          - * + * *
                          Something went wrong
                          *
                          * @@ -207,15 +207,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 -} +} & UseLiveQueryFlags /** * Subscribe to an existing live query collection @@ -246,7 +238,7 @@ export function useLiveQuery( * *
                          Loading...
                          *
                          - * + * *
                          Error loading data
                          *
                          * @@ -270,15 +262,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 -} +} & UseLiveQueryFlags // Overload 3b: Accept pre-created live query collection with singleResult: true export function useLiveQuery< @@ -298,15 +282,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 -} +} & UseLiveQueryFlags // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( @@ -369,9 +345,7 @@ export function useLiveQuery( name: `TanstackDBStatus`, }, ) - const [persistedStatus, setLocalStatus] = - createSignal(`unavailable`) - const [persistedError, setLocalError] = createSignal(undefined) + const [health, setHealth] = createSignal(undefined) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( @@ -435,6 +409,7 @@ export function useLiveQuery( return [] } setStatus(currentCollection.status) + setHealth(currentCollection.health) try { const observer = createLiveQueryObserver(currentCollection) localWait = { @@ -443,14 +418,14 @@ export function useLiveQuery( ready: false, } try { - await observer.preloadForInitialRender() + await observer.preload() } catch (error) { if (generation === resourceGeneration) releaseLocalWait() throw error } if (generation !== resourceGeneration) return data localWait.ready = true - // `preloadForInitialRender` waits for readiness but does not itself + // `preload` waits for readiness but does not itself // retain the Collection after it settles. Hold one observer // subscription across Solid's ready-before-commit Suspense gap. observer.subscribe(() => {}) @@ -482,6 +457,7 @@ export function useLiveQuery( } syncDataFromCollection(currentCollection) setStatus(currentCollection.status) + setHealth(currentCollection.health) }) return data }, @@ -496,8 +472,7 @@ export function useLiveQuery( const currentCollection = collection() if (!currentCollection) { setStatus(`disabled` as const) - setLocalStatus(`unavailable`) - setLocalError(undefined) + setHealth(undefined) state.clear() rowsByKey.clear() rowsCollection = undefined @@ -544,8 +519,7 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) const snapshot = observer.getSnapshot() setStatus(snapshot.status) - setLocalStatus(snapshot.persistedStatus) - setLocalError(snapshot.persistedError) + setHealth(snapshot.health) }) }, ) @@ -560,8 +534,7 @@ export function useLiveQuery( syncDataFromCollection(currentCollection) const snapshot = observer.getSnapshot() setStatus(snapshot.status) - setLocalStatus(snapshot.persistedStatus) - setLocalError(snapshot.persistedError) + setHealth(snapshot.health) }) onCleanup(() => { @@ -617,34 +590,34 @@ export function useLiveQuery( return status() === `ready` || status() === `disabled` }, }, - persistedStatus: { + isIdle: { get() { - return persistedStatus() + return status() === `idle` }, }, - isPersistedReady: { + isLoadingError: { get() { - return persistedStatus() === `ready` + return status() === `error` }, }, - persistedError: { + isCrashed: { get() { - return persistedError() + return status() === `crashed` }, }, - isIdle: { + isCleanedUp: { get() { - return status() === `idle` + return status() === `cleaned-up` }, }, - isError: { + isSyncError: { get() { - return status() === `error` + return status() === `ready` && health()?.hasErrors === true }, }, - isCleanedUp: { + health: { get() { - return status() === `cleaned-up` + return health() }, }, }) diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 232543cb2d..d3572fb6e2 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -28,6 +28,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-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 { @@ -106,27 +108,12 @@ function makePrecreated( 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() { @@ -146,12 +133,12 @@ function makeHandle( state: result?.state, status: result?.status, isReady: result?.isReady, - 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`, + health: result?.health, }) }, flush: settle, @@ -234,6 +221,12 @@ const solidDriver: LiveQueryDriver = { createCollection, `conformance-solid-lagging-${sourceSeq++}`, ), + makeSyncSource: (data: ReadonlyArray) => + makeSyncSource( + createCollection, + `conformance-solid-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, @@ -243,7 +236,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: [], @@ -299,7 +292,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( @@ -311,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`]) { + 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/suspense-after-readiness.test.tsx b/packages/solid-db/tests/suspense-after-readiness.test.tsx new file mode 100644 index 0000000000..39bb53d756 --- /dev/null +++ b/packages/solid-db/tests/suspense-after-readiness.test.tsx @@ -0,0 +1,76 @@ +/** + * 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 status: (() => string) | undefined + let cleanupQuery: (() => Promise) | undefined + function Title() { + const query = useLiveQuery((q) => q.from({ post: source })) + status = () => query.status + cleanupQuery = () => query.collection.cleanup() + return
                          {query()[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(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(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 cleanupQuery?.() + await source.cleanup() + } + }) +}) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 85f90c60dd..7b6d1d13a4 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -9,7 +9,6 @@ import { eq, gt, toArray, - waitForPersistedInitialRender, } from '@tanstack/db' import { For, @@ -2262,120 +2261,6 @@ describe(`Query Collections`, () => { }, ) - 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 - markError: (error: unknown) => void - } - | undefined - const source = createCollection<Person>({ - id: `solid-persisted-${outcome}`, - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady, markError }) => { - sync = { - begin, - write: (row) => write({ type: `insert`, value: row }), - commit: () => { - commit() - }, - markReady, - markError, - } - 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, - waitForInitialRender: waitForPersistedInitialRender, - getOrStartNetworkDeadline: () => - (networkDeadlineAt ??= Date.now() + networkTimeoutMs), - getSnapshot: () => ({ status: persistedStatus }), - subscribe: (listener: () => void) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - }, - }, - ) - let cleanupQuery: (() => Promise<void>) | undefined - const statusView = renderHook(() => useLiveQuery(() => source)) - - function LocalPerson() { - const query = useLiveQuery((q) => q.from({ person: source })) - cleanupQuery = () => query.collection.cleanup() - return <div data-testid="local-person">{query()[0]?.name}</div> - } - const view = render(() => ( - <Suspense fallback={<div data-testid="local-waiting">Waiting</div>}> - <LocalPerson /> - </Suspense> - )) - 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!.markError(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<Person>({ diff --git a/packages/svelte-db/skills/svelte-db/SKILL.md b/packages/svelte-db/skills/svelte-db/SKILL.md index 295103a302..9b9f81fa71 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.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 40d937c652..e5082bb15e 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -58,7 +58,6 @@ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit< fetchNextPage: () => Promise<void> hasNextPage: boolean isFetchingNextPage: boolean - error: unknown } export type UseLiveInfiniteQueryReturnWithCollection< @@ -75,7 +74,6 @@ export type UseLiveInfiniteQueryReturnWithCollection< fetchNextPage: () => Promise<void> hasNextPage: boolean isFetchingNextPage: boolean - error: unknown } type EnabledLiveQueryReturn<TContext extends Context> = ReturnType< @@ -252,24 +250,21 @@ export function useLiveInfiniteQuery<TContext extends Context>( 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 }, - 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<Array<InferResultType<TContext>[number]>> }, @@ -282,8 +277,8 @@ export function useLiveInfiniteQuery<TContext extends Context>( get isFetchingNextPage() { return snapshot.isFetchingNextPage }, - get error() { - return snapshot.error + 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 10c8967112..9d5dd3e27b 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -24,12 +24,12 @@ import type { DbClient, DeferredLiveQueryCollections, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryKey, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -52,7 +52,9 @@ function snapshotRows(data: unknown): Array<any> { * @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 `health` has a problem * @property isCleanedUp - True when query has been cleaned up */ export interface UseLiveQueryReturn<T extends object, TData = Array<T>> { @@ -62,12 +64,15 @@ export interface UseLiveQueryReturn<T extends object, TData = Array<T>> { status: CollectionStatus isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - 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. 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 `health`. */ + isSyncError: boolean + health: HealthNode | undefined } type InferConditionalResultType<TContext extends Context> = @@ -95,12 +100,15 @@ export interface UseLiveQueryReturnWithCollection< status: CollectionStatus isLoading: boolean isReady: boolean - persistedStatus: LiveQueryPersistedStatus - 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. 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 `health`. */ + isSyncError: boolean + health: HealthNode | undefined } type MaybeGetter<T> = T | (() => T) @@ -170,8 +178,8 @@ function toValue<T>(value: MaybeGetter<T>): 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 @@ -196,7 +204,7 @@ function toValue<T>(value: MaybeGetter<T>): T { * // In template: * // {#if todosQuery.isLoading} * // <div>Loading...</div> - * // {:else if todosQuery.isError} + * // {:else if todosQuery.isLoadingError} * // <div>Error: {todosQuery.status}</div> * // {:else} * // <ul> @@ -252,7 +260,7 @@ export function useLiveQuery<TContext extends Context>( * // 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> @@ -301,7 +309,7 @@ export function useLiveQuery<TContext extends Context>( * // 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)} @@ -462,8 +470,7 @@ export function useLiveQuery( // Track collection status reactively let status = $state(initialSnapshot.status) - let persistedStatus = $state(initialSnapshot.persistedStatus) - let persistedError = $state.raw<unknown>(initialSnapshot.persistedError) + let health = $state.raw<HealthNode | undefined>(initialSnapshot.health) const syncFromObserver = ( observer: LiveQueryObserver<any, any>, @@ -471,8 +478,7 @@ export function useLiveQuery( ) => { const snapshot = observer.getSnapshot() status = snapshot.status as CollectionStatus - persistedStatus = snapshot.persistedStatus - persistedError = snapshot.persistedError + health = snapshot.health untrack(() => { if (changes && changes.length > 0) { for (const change of changes) { @@ -557,23 +563,23 @@ 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` }, - get isError() { + get isLoadingError() { return status === `error` }, + get isCrashed() { + return status === `crashed` + }, get isCleanedUp() { return status === `cleaned-up` }, + get isSyncError() { + return status === `ready` && health?.hasErrors === true + }, + 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 0868687354..ba350edf98 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -27,6 +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 { 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, @@ -104,27 +106,12 @@ function makePrecreated( 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() { @@ -143,10 +130,10 @@ 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, - isError: query?.isError, + isLoadingError: query?.isLoadingError, + isCrashed: query?.isCrashed, + isSyncError: query?.isSyncError, + health: query?.health, // svelte-db exposes no `isEnabled`; derive it from status (status-derived). isEnabled: query?.status !== `disabled`, }) @@ -225,6 +212,12 @@ const svelteDriver: LiveQueryDriver = { createCollection, `conformance-svelte-lagging-${sourceSeq++}`, ), + makeSyncSource: <T extends { id: string }>(data: ReadonlyArray<T>) => + makeSyncSource<T>( + createCollection, + `conformance-svelte-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, @@ -243,7 +236,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( @@ -255,9 +250,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 601061cb2a..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, - error: result.error, + 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 27c3a9decb..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<SyncConfig<TItem, TKey>[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady, markError, collection } = params + const { begin, write, commit, markReady, fail } = params let cancelled = false let periodicCleanupTask: ReturnType<typeof setInterval> | undefined const cleanup = () => { @@ -397,6 +397,20 @@ 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. 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) { + streamEnded = true + fail(error) + } else if (!closed) + console.error(`TrailBase subscription failed`, error) + } + async function start() { let reader: ReadableStreamDefaultReader<Event> | undefined try { @@ -421,13 +435,14 @@ export function trailBaseCollectionOptions< subscribedReader.releaseLock() if (eventReader === subscribedReader) eventReader = undefined }) - .catch((error: unknown) => { - if (!cancelled && collection.status === `loading`) { - markError(error) - } else if (!cancelled) { - console.error(`TrailBase subscription failed`, error) - } - }) + .then( + () => + reportStreamEnd( + new Error(`TrailBase change stream closed`), + true, + ), + (error: unknown) => reportStreamEnd(error), + ) listening = true settleListener() @@ -438,17 +453,15 @@ export function trailBaseCollectionOptions< if (cancelled) return fullSyncCompleted = true } - if (!cancelled && collection.status === `loading`) { + if (!cancelled && !streamEnded) { 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) - } + reportStreamEnd(error, true) return } diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index cd46d83e63..04e2c2f919 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -81,7 +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. 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 b2ae460952..77cbc188b2 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 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. * * A controlled RecordApi and native ReadableStream provide explicit gates for @@ -351,10 +353,16 @@ 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} error`, + ).toMatchObject({ + hasErrors: true, + failureReason: null, + error: expect.objectContaining( + isFailure ? { cause: failure } : {}, + ), }) } assertReports() @@ -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 the Collection's `error`, so an application can + * 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. * * 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, + * The Collection's `error` stays, and nothing clears it in this sync run. */ it.each([`close`, `read-error`] as const)( - `keeps rows 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<Event> @@ -1214,6 +1227,14 @@ 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 stopped = { + hasErrors: true, + failureReason: null, + error: expect.anything(), + } + expect(collection.health, `${ending} error after the end`).toMatchObject( + stopped, + ) service.set(1, { id: 1, data: `v2` }) await turn() @@ -1229,6 +1250,67 @@ it.each([`close`, `read-error`] as const)( expect(rows(), `${ending} a later load lists current rows`).toEqual( sortedRows(service.values()), ) + expect(collection.health, `${ending} error after the load`).toMatchObject( + stopped, + ) + } finally { + await collection.cleanup() + reported.mockRestore() + } + }, +) + +/** + * ## 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<Event>({ start() {} }) + const api = new MockRecordApi<RaceRow>() + 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() diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index 63448f0988..2edc529c33 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -268,10 +268,11 @@ 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 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({ cause: failure }) expect(cancel).toHaveBeenCalledOnce() expect(stream.locked).toBe(false) await collection.cleanup() @@ -327,12 +328,12 @@ 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 + // stop: the Collection's `error`; a stream failure is its cause. + expect(reported).not.toHaveBeenCalled() + expect(collection.health.error).toMatchObject( + ending === `error` ? { cause: failure } : {}, + ) await collection.cleanup() await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/packages/vue-db/skills/vue-db/SKILL.md b/packages/vue-db/skills/vue-db/SKILL.md index 3779be9a5d..b5486265c7 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, 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 33b93631da..df1b0295a1 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -15,9 +15,9 @@ import type { CollectionStatus, Context, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, UtilsRecord, @@ -56,18 +56,20 @@ export interface UseLiveInfiniteQueryReturn< status: ComputedRef<CollectionStatus> isLoading: ComputedRef<boolean> isReady: ComputedRef<boolean> - persistedStatus: ComputedRef<LiveQueryPersistedStatus> - isPersistedReady: ComputedRef<boolean> - persistedError: ComputedRef<unknown | undefined> isIdle: ComputedRef<boolean> - isError: ComputedRef<boolean> + /** The first load failed: there is nothing to show. */ + isLoadingError: ComputedRef<boolean> + /** 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 `health`. */ + isSyncError: ComputedRef<boolean> pages: ComputedRef<Array<Array<InferResultType<TContext>[number]>>> pageParams: ComputedRef<Array<number>> fetchNextPage: () => Promise<void> hasNextPage: ComputedRef<boolean> isFetchingNextPage: ComputedRef<boolean> - error: ComputedRef<unknown> + health: ComputedRef<HealthNode | undefined> } export interface UseLiveInfiniteQueryReturnWithCollection< @@ -81,18 +83,20 @@ export interface UseLiveInfiniteQueryReturnWithCollection< status: ComputedRef<CollectionStatus> isLoading: ComputedRef<boolean> isReady: ComputedRef<boolean> - persistedStatus: ComputedRef<LiveQueryPersistedStatus> - isPersistedReady: ComputedRef<boolean> - persistedError: ComputedRef<unknown | undefined> isIdle: ComputedRef<boolean> - isError: ComputedRef<boolean> + /** The first load failed: there is nothing to show. */ + isLoadingError: ComputedRef<boolean> + /** 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 `health`. */ + isSyncError: ComputedRef<boolean> pages: ComputedRef<Array<Array<TResult>>> pageParams: ComputedRef<Array<number>> fetchNextPage: () => Promise<void> hasNextPage: ComputedRef<boolean> isFetchingNextPage: ComputedRef<boolean> - error: ComputedRef<unknown> + health: ComputedRef<HealthNode | undefined> } /** @@ -254,12 +258,11 @@ 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), - 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<Array<InferResultType<TContext>[number]>>, @@ -268,6 +271,6 @@ export function useLiveInfiniteQuery< fetchNextPage: () => fetchNextLiveQueryWindowPage(controller.value), hasNextPage: computed(() => snapshot.value.hasNextPage), isFetchingNextPage: computed(() => snapshot.value.isFetchingNextPage), - error: computed(() => snapshot.value.error), + health: computed(() => snapshot.value.health), } } diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index e75db9be0e..bc6a605bf8 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -22,11 +22,11 @@ import type { CollectionStatus, Context, GetResult, + HealthNode, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryObserver, - LiveQueryPersistedStatus, NonSingleResult, QueryBuilder, SingleResult, @@ -50,22 +50,31 @@ function snapshotRows(data: unknown): Array<any> { * @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 */ -export interface UseLiveQueryReturn<TContext extends Context> { - state: ComputedRef<Map<string | number, GetResult<TContext>>> - data: ComputedRef<InferResultType<TContext>> - collection: ComputedRef<Collection<GetResult<TContext>, string | number, {}>> +/** Status, persistence, and error refs every `useLiveQuery` result has. */ +export interface UseLiveQueryFlags { status: ComputedRef<CollectionStatus> isLoading: ComputedRef<boolean> isReady: ComputedRef<boolean> - persistedStatus: ComputedRef<LiveQueryPersistedStatus> - isPersistedReady: ComputedRef<boolean> - persistedError: ComputedRef<unknown | undefined> isIdle: ComputedRef<boolean> - isError: ComputedRef<boolean> + /** The first load failed: there is nothing to show. */ + isLoadingError: ComputedRef<boolean> + /** 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 `health`. */ + isSyncError: ComputedRef<boolean> + health: ComputedRef<HealthNode | undefined> +} + +export interface UseLiveQueryReturn< + TContext extends Context, +> extends UseLiveQueryFlags { + state: ComputedRef<Map<string | number, GetResult<TContext>>> + data: ComputedRef<InferResultType<TContext>> + collection: ComputedRef<Collection<GetResult<TContext>, string | number, {}>> } type InferConditionalResultType<TContext extends Context> = @@ -90,38 +99,20 @@ export interface UseLiveQueryReturnWithCollection< T extends object, TKey extends string | number, TUtils extends Record<string, any>, -> { +> extends UseLiveQueryFlags { state: ComputedRef<Map<TKey, T>> data: ComputedRef<Array<T>> collection: ComputedRef<Collection<T, TKey, TUtils>> - status: ComputedRef<CollectionStatus> - isLoading: ComputedRef<boolean> - isReady: ComputedRef<boolean> - persistedStatus: ComputedRef<LiveQueryPersistedStatus> - isPersistedReady: ComputedRef<boolean> - persistedError: ComputedRef<unknown | undefined> - isIdle: ComputedRef<boolean> - isError: ComputedRef<boolean> - isCleanedUp: ComputedRef<boolean> } export interface UseLiveQueryReturnWithSingleResultCollection< T extends object, TKey extends string | number, TUtils extends Record<string, any>, -> { +> extends UseLiveQueryFlags { state: ComputedRef<Map<TKey, T>> data: ComputedRef<T | undefined> collection: ComputedRef<Collection<T, TKey, TUtils> & SingleResult> - status: ComputedRef<CollectionStatus> - isLoading: ComputedRef<boolean> - isReady: ComputedRef<boolean> - persistedStatus: ComputedRef<LiveQueryPersistedStatus> - isPersistedReady: ComputedRef<boolean> - persistedError: ComputedRef<unknown | undefined> - isIdle: ComputedRef<boolean> - isError: ComputedRef<boolean> - isCleanedUp: ComputedRef<boolean> } /** @@ -162,13 +153,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: * // <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> @@ -209,13 +200,13 @@ export function useLiveQuery<TContext extends Context>( * * @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: * // <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> */ @@ -255,11 +246,11 @@ export function useLiveQuery<TContext extends Context>( * * @example * // 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> @@ -373,8 +364,7 @@ export function useLiveQuery( const status = ref( collection.value ? collection.value.status : (`disabled` as const), ) - const persistedStatus = ref<LiveQueryPersistedStatus>(`unavailable`) - const persistedError = shallowRef<unknown>(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. @@ -383,8 +373,7 @@ export function useLiveQuery( const syncFromObserver = (observer: LiveQueryObserver<any, any>) => { const snapshot = observer.getSnapshot() status.value = snapshot.status as CollectionStatus - persistedStatus.value = snapshot.persistedStatus - persistedError.value = snapshot.persistedError + health.value = snapshot.health internalData.value = snapshotRows(snapshot.data) } @@ -399,8 +388,7 @@ export function useLiveQuery( // Handle null collection (disabled query) if (!currentCollection) { status.value = `disabled` as const - persistedStatus.value = `unavailable` - persistedError.value = undefined + health.value = undefined state.clear() internalData.value = [] return @@ -463,11 +451,13 @@ 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`), - 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` && health.value?.hasErrors === true, + ), + health: computed(() => health.value), } } diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 807a847e66..e91270c0ec 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -27,6 +27,8 @@ import { import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-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 { @@ -105,27 +107,12 @@ function makePrecreated( 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() { @@ -142,12 +129,12 @@ function makeHandle(result: any, scope: ReturnType<typeof effectScope>) { state: result.state?.value, status: result.status?.value, isReady: result.isReady?.value, - 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`, + health: result.health?.value, }) }, flush: settle, @@ -232,6 +219,12 @@ const vueDriver: LiveQueryDriver = { createCollection, `conformance-vue-lagging-${sourceSeq++}`, ), + makeSyncSource: <T extends { id: string }>(data: ReadonlyArray<T>) => + makeSyncSource<T>( + createCollection, + `conformance-vue-health-${sourceSeq++}`, + data, + ), makePrecreated, makeErrorSource, mount, @@ -292,7 +285,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() @@ -312,9 +307,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 a8828f86b1..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, - error: result.error.value, + isLoadingError: result.isLoadingError.value, status: result.status.value, collection: result.collection.value, } 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`,