Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
124 commits
Select commit Hold shift + click to select a range
a90c11b
docs: plan final initial errors and the source health channel
Oct 13, 2026
8f6afbd
docs: plan TrailBase stream-end reporting without reconnect
Oct 13, 2026
c0985d0
feat(db)!: make an initial sync error final and add Collection health
Oct 13, 2026
cec4e5a
test(db): add the source failure and health oracle
Oct 13, 2026
3ffc6d5
feat(db): report health to Effects and expose errors to live-query re…
Oct 13, 2026
4694a44
test(db): extend the health oracle with order, identity, include, poo…
Oct 13, 2026
585dfdc
feat(db): order health after status, keep error-tree identity, add in…
Oct 13, 2026
58ced49
fix(db): gate every Effect on its settled initial loads
Oct 13, 2026
f2a7b89
test(db): add Effect request-error and include-instance lanes to the …
Oct 13, 2026
e8508df
fix(db): make post-gate Effect load failures request errors and key i…
Oct 13, 2026
70df054
Merge remote-tracking branch 'origin/main' into feat/source-health-ch…
Oct 14, 2026
b1304a0
feat(db-sqlite-persistence-core): forward upstream health reports thr…
Oct 14, 2026
7342882
feat(adapters): implement post-readiness health error reporting
Oct 14, 2026
7e42dc9
feat(hooks): add errors return value to all framework hooks
Oct 14, 2026
aa2cde8
feat(adapters): add health reporting for query-db and PowerSync
Oct 14, 2026
0238a58
fix(adapters): remove dead code and add post-readiness error reporting
Oct 14, 2026
0ae399e
docs(glossary): add Collection health terms
Oct 14, 2026
8c6cf14
docs(db): align health-channel wording with the glossary
Oct 14, 2026
4a66e2a
fix(hooks): implement errors runtime and add conformance oracle scena…
Oct 14, 2026
f143b43
docs(db): regenerate error messages after the glossary wording
Oct 14, 2026
3cb1023
Merge remote-tracking branch 'origin/feat/source-health-channel' into…
Oct 14, 2026
fcd0230
Merge remote-tracking branch 'origin/feat/source-health-channel' into…
Oct 14, 2026
f742b71
fix(conformance): add errors field to all driver result mappers
Oct 14, 2026
cac0119
fix: correct conformance scenario count (34 not 35)
Oct 14, 2026
381e07f
fix(angular): add errors to raw result type test
Oct 14, 2026
b4bbde5
fix(adapters): improve health reporting finality logic and test coverage
Oct 14, 2026
4b36cbe
fix(db-sqlite-persistence-core): crash Collection on terminal local d…
Oct 14, 2026
08c2c14
test(electric): update oracle tests for post-readiness health law
Oct 14, 2026
0beeafa
fix(db-sqlite-persistence-core): report terminal failures through cra…
Oct 14, 2026
fef9015
fix(db-sqlite-persistence-core): use explicit isTerminal parameter fo…
Oct 14, 2026
c341578
test(query-db): flip initial error finality tests to new law per PLAN…
Oct 14, 2026
83f56c7
fix(db): publish health-only changes to live-query observers
Oct 14, 2026
990ac17
test(conformance): judge Collection health through every framework hook
Oct 14, 2026
5813681
docs(glossary): add crash to the Collection health terms
Oct 14, 2026
51e98e9
docs(glossary): include request errors in a live query's Collection h…
Oct 14, 2026
c3b096c
fix(db-sqlite-persistence-core): crash on persistence failures by origin
Oct 14, 2026
de5faab
fix(db): end live queries and Effects when a source Collection crashes
Oct 14, 2026
2a8466c
Merge remote-tracking branch 'origin/health/hooks' into feat/source-h…
Oct 14, 2026
d38e899
Merge remote-tracking branch 'origin/health/persistence' into feat/so…
Oct 14, 2026
5b9482c
Revert "test(electric): update oracle tests for post-readiness health…
Oct 14, 2026
cf060eb
Merge remote-tracking branch 'origin/feat/source-health-channel' into…
Oct 14, 2026
ec4bb7b
Merge remote-tracking branch 'origin/feat/source-health-channel' into…
Oct 14, 2026
a98702f
test(electric): judge a give-up after Collection readiness as final h…
Oct 14, 2026
08b064e
fix(trailbase-db-collection): report every stream end as a final heal…
Oct 14, 2026
77cf7e7
revert(rxdb-db-collection): drop the unreachable post-readiness branch
Oct 14, 2026
cd02670
Merge remote-tracking branch 'origin/feat/source-health-channel' into…
Oct 14, 2026
446a69e
fix(powersync-db-collection): report a tracking outage as Collection …
Oct 14, 2026
17447cb
fix(query-db-collection): make Collection health mirror utils.lastError
Oct 14, 2026
c6c9c8a
docs: describe Collection health, the error tree, and restarts
Oct 14, 2026
60cd0b6
docs: add the health changeset, coverage owner, and plan status
Oct 14, 2026
c2e3b77
docs(skills): teach Collection health and the error tree
Oct 14, 2026
12948ff
docs(db): restore the release-pass comment in Effect disposal
Oct 14, 2026
e815ab5
refactor(frameworks): share the live-query result flags across overloads
Oct 15, 2026
afbb57d
refactor(db): share request-error tracking and derive the sync error
Oct 15, 2026
8bcbe46
refactor(db): find a live query's tree sources through its builder
Oct 15, 2026
6e013bd
refactor!: make markError the single way a sync adapter gives up
Oct 15, 2026
16ff7fe
refactor!: report infinite-query page failures in the error tree only
Oct 15, 2026
6f1c80c
refactor(query-db-collection)!: report Query failures only through Co…
Oct 15, 2026
8a95d3b
docs(oracle-coverage): describe the simplified health grammar and wit…
Oct 15, 2026
9dbcaf8
test(conformance): require every binding to expose the exact error value
Oct 15, 2026
c3850b7
Merge remote-tracking branch 'origin/main' into feat/source-health-ch…
Oct 15, 2026
dd52111
fix: close review findings in the health channel
Oct 15, 2026
e25e750
docs(changeset): describe crashes and multi-Query health in the relea…
Oct 15, 2026
020a676
chore(db): refresh the private-member mangle cache
Oct 15, 2026
5873ad0
test(indexed-db): expect a final health error when an active receiver…
Oct 15, 2026
b1adea4
fix: close CodeRabbit findings in the health channel
Oct 15, 2026
369ef22
feat(db)!: split status into loading error and crash; failureReason/e…
Oct 16, 2026
075c657
feat(db)!: refuse cleanup under active dependents; add collection.rel…
Oct 16, 2026
6e95d1a
feat(db)!: fold persisted readiness into Collection status; add findP…
Oct 16, 2026
59b9e98
Merge origin/main into feat/source-health-channel
Oct 16, 2026
a01a9dd
docs: describe the fail-then-reload cleanup-restart history
Oct 16, 2026
807ac2b
build: refresh the mangle cache and check the new collection errors
Oct 16, 2026
24c5c3b
test(persistence): a follower's failed apply crashes its source
Oct 16, 2026
f3cd710
test(indexed-db): a closed native connection crashes the Collection
Oct 18, 2026
761969e
fix(db): close reload, request-order, and persisted report-order gaps
Oct 21, 2026
8a2e2a5
chore: describe recursive reload and reload crash rules in the changeset
Oct 21, 2026
11fca7b
docs: fix the errors:change example, glossary status list, and a list…
Oct 21, 2026
26946b3
fix: settle reloads from error in every sync adapter; keep reload rows
Oct 21, 2026
649b56f
fix: close the high-review findings and the joined-filter window stall
Oct 21, 2026
b9b33b2
Merge origin/main into feat/source-health-channel
Oct 21, 2026
846c0f5
fix(db): clear an Effect's request error when its failed demand is re…
Oct 21, 2026
e5d7b3a
chore: tell custom adapters to keep run readiness in sync
Oct 21, 2026
a845331
refactor(db): one recovery path for a failed query; drop include roll-up
Oct 21, 2026
95fbf6c
Merge origin/main into feat/source-health-channel
Oct 21, 2026
35ed337
refactor(db): drop Effect request errors; a failed load ends an Effect
Oct 21, 2026
cb47c50
fix(rxdb): fail the sync run when the change stream errors
Oct 21, 2026
1804c48
Merge origin/main into feat/source-health-channel
Oct 21, 2026
f32d0d4
fix(db): replay on-demand demand when a reload's truncate applies ins…
Oct 21, 2026
3849906
test(query-db): report the random campaign's seed when the test times…
Oct 21, 2026
9c11072
ci: apply automated fixes
autofix-ci[bot] Oct 21, 2026
84c86d3
fix(db): settle reloads by their own run, not the status they started…
Oct 21, 2026
60319f9
fix(db): end an Effect over a stopped source; clear request errors on…
Oct 21, 2026
d846b18
fix(db): settle a reload whose ready callback throws; announce cleanu…
Oct 21, 2026
7f8d60f
Merge branch 'feat/source-health-channel' of github.com:TanStack/db i…
Oct 21, 2026
72f6fdb
docs(db): a crash is not recovered in place; a full cleanup starts fresh
Oct 21, 2026
43b85aa
fix: settle review findings on reload, Effect start, cleanup, and que…
Oct 21, 2026
39e4bdc
ci: apply automated fixes
autofix-ci[bot] Oct 21, 2026
8a46352
test(db): widen the source-health grammar to rows, open transactions,…
Oct 21, 2026
d883f7b
test(db): widen the source-health grammar to steps while cleanup is p…
Oct 21, 2026
77dd19e
test(db): generate on-demand demand histories; report a failed replay
Oct 21, 2026
cad1c7a
Merge branch 'feat/source-health-channel' of github.com:TanStack/db i…
Oct 21, 2026
314a5ee
Merge remote-tracking branch 'origin/main' into feat/source-health-ch…
Oct 21, 2026
099c8a3
test(db): type the source-health step helper by its source-step kinds
Oct 21, 2026
4b719d2
fix(db): a live query's reload starts it after a cascade ended it
Oct 22, 2026
82cb87b
test(db): generate lazy join demand histories; retire a request error…
Oct 22, 2026
912d03f
ci: apply automated fixes
autofix-ci[bot] Oct 22, 2026
e883289
docs: record the maintainer's decisions on replay request errors and …
Oct 22, 2026
369a00b
fix(db): settle the xhigh review of the widened grammar
Oct 22, 2026
0c2287b
fix(db): close xhigh review findings on reload replay, readiness, and…
Oct 22, 2026
b1226ce
refactor(db): start a reload's replay when it retires the sync run
Oct 22, 2026
31a3a11
fix(db): close the open lazy and window histories the wider grammars …
Oct 22, 2026
393e5e8
fix(db): a newer replay replaces a load that failed during an older one
Oct 22, 2026
889ff08
test(solid-db): Suspense keeps rows shown through a sync problem afte…
Oct 22, 2026
7a4eb68
docs(changeset): state the retired-cleanup crash and the reload's loa…
Oct 22, 2026
8668912
fix(db): a reload keeps a direct subscriber's rows until its first co…
Oct 22, 2026
6b4e762
refactor(db): name the health tree `collection.health`
Oct 22, 2026
46fe9e7
fix(db): a truncate after a reload's replay replays the demand again
Oct 22, 2026
6bf6579
fix(db): skip only a reload's own truncate, not every truncate during it
Oct 23, 2026
838ced7
fix(db): a reload starts its replay at retire only for live queries
Oct 23, 2026
83a51a6
Merge origin/main into feat/source-health-channel
Oct 23, 2026
b90fa83
fix(db): declare source-health reach and fix what it found
Oct 23, 2026
9c35e0b
test(db): unify the source-health models and fix the replay defects t…
Oct 23, 2026
e093733
test(db): model parked demand in the source-health demand layer
Oct 23, 2026
62e232d
Merge origin/main into feat/source-health-channel
Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 0 additions & 6 deletions .changeset/persisted-initial-render-split.md

This file was deleted.

38 changes: 38 additions & 0 deletions .changeset/source-health-channel.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 11 additions & 3 deletions docs/collections/electric-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/collections/indexed-db-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down
18 changes: 12 additions & 6 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HealthNode> // 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.
Loading
Loading