From e6587a1c351a639c2a0e4e5fec27dba043b0e425 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 09:59:01 -0600 Subject: [PATCH 01/17] feat: add @tanstack/preact-db and share adapter identity and window binding Add the Preact adapter (useLiveQuery, useLiveInfiniteQuery) and scope the 1.0 query-identity laws to bindings that compare identity: React, Preact, and DbClient-backed Svelte queries throw UnhashableQueryIRError and SourceCollectionIdReuseError; Vue, Solid, Angular, and client-free Svelte compile any query and follow the current source. The shared conformance suite branches on each driver's comparesQueryIdentity policy. Collect adapter-only helpers into the live-query adapter layer, add identifyLiveQuery (identity + source-ID claim + deferral release) and bindLiveQueryWindow (shared window binding for every infinite hook), and make rejected query inputs release their deferred sync starts. Co-authored-by: Isaac --- .changeset/db-query-identity-laws.md | 9 + .changeset/preact-db-adapter.md | 5 + .changeset/react-db-unhashable-throws.md | 7 + .changeset/svelte-db-query-identity.md | 7 + .changeset/vue-db-window-binding.md | 5 + _artifacts/skill_tree.yaml | 17 + docs/config.json | 21 + docs/contributing/glossary.md | 4 + docs/contributing/oracle-coverage.md | 4 +- docs/errors.md | 20 + docs/framework/preact/overview.md | 154 +++++++ .../functions/useLiveInfiniteQuery.md | 106 +++++ .../reference/functions/useLiveQuery.md | 435 ++++++++++++++++++ docs/framework/preact/reference/index.md | 23 + .../UseConditionalLiveQueryResult.md | 208 +++++++++ .../interfaces/UseLiveQueryResult.md | 208 +++++++++ .../ConditionalUseLiveQueryConfig.md | 26 ++ .../UseLiveInfiniteQueryConfig.md | 51 ++ .../UseLiveInfiniteQueryReturn.md | 17 + ...seLiveInfiniteQueryReturnWithCollection.md | 24 + .../type-aliases/UseLiveQueryConfig.md | 16 + .../type-aliases/UseLiveQueryStatus.md | 10 + docs/framework/react/overview.md | 17 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../react/reference/functions/useLiveQuery.md | 22 +- .../ConditionalUseLiveQueryConfig.md | 2 +- .../reference/type-aliases/LiveQueryKey.md | 2 +- .../UseLiveInfiniteQueryConfig.md | 10 +- .../UseLiveInfiniteQueryReturn.md | 2 +- ...seLiveInfiniteQueryReturnWithCollection.md | 40 +- .../type-aliases/UseLiveQueryConfig.md | 2 +- .../type-aliases/UseLiveQueryStatus.md | 2 +- docs/framework/svelte/overview.md | 15 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../reference/functions/useLiveQuery.md | 10 +- .../interfaces/UseLiveQueryReturn.md | 26 +- .../UseLiveQueryReturnWithCollection.md | 26 +- .../ConditionalUseLiveQueryReturn.md | 2 +- .../type-aliases/LiveInfiniteQueryConfig.md | 2 +- .../UseLiveInfiniteQueryConfig.md | 2 +- .../UseLiveInfiniteQueryReturn.md | 2 +- ...seLiveInfiniteQueryReturnWithCollection.md | 2 +- .../type-aliases/UseLiveQueryConfig.md | 2 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../interfaces/UseLiveInfiniteQueryReturn.md | 38 +- ...seLiveInfiniteQueryReturnWithCollection.md | 38 +- .../type-aliases/LiveInfiniteQueryConfig.md | 2 +- .../UseLiveInfiniteQueryConfig.md | 2 +- docs/guides/live-queries.md | 29 +- docs/guides/ssr.md | 13 +- docs/installation.md | 8 + .../classes/SourceCollectionIdReuseError.md | 248 ++++++++++ docs/reference/classes/TanStackDBError.md | 1 + .../assertLiveQueryWindowManyResult.md | 24 - .../functions/bindLiveQueryWindow.md | 39 ++ .../functions/claimLiveQuerySourceIds.md | 38 ++ .../compareLiveQueryWindowDependencies.md | 40 -- .../functions/createLiveQueryObserver.md | 2 +- .../createLiveQuerySourceIdClaims.md | 14 + docs/reference/functions/getLiveQueryHash.md | 2 +- docs/reference/functions/getLiveQueryKey.md | 24 + .../functions/getLiveQueryStatusFlags.md | 2 +- .../getLiveQueryWindowCollectionWarning.md | 31 -- .../functions/getLiveQueryWindowInputKind.md | 28 -- .../functions/getPersistedReadinessSource.md | 2 +- .../functions/getPreparedLiveQueryIdentity.md | 2 +- .../functions/getPreparedLiveQuerySources.md | 2 +- .../functions/getPublicCollection.md | 2 +- .../functions/getSourceObjectToken.md | 22 + .../functions/hasLiveQueryWindowLeases.md | 24 - docs/reference/functions/identifyLiveQuery.md | 43 ++ docs/reference/functions/isCollection.md | 2 +- .../functions/isLiveQueryWindowCollection.md | 24 - .../functions/isSingleResultCollection.md | 2 +- .../functions/liveQueryWindowMatches.md | 38 -- .../normalizeLiveQueryWindowPageSize.md | 24 - .../functions/prepareLiveQueryValue.md | 7 +- .../functions/readLiveQueryWindowPageShape.md | 29 ++ .../functions/resolveLiveQueryValue.md | 7 +- .../functions/resolveLiveQueryWindowInput.md | 34 -- .../resumeDeferredLiveQueryCollections.md | 2 +- .../shouldPreserveLiveQueryWindowPageCount.md | 50 -- .../waitForPersistedInitialRender.md | 40 ++ docs/reference/index.md | 29 +- .../CreateLiveQueryObserverOptions.md | 10 +- .../interfaces/LiveQueryIdentityScope.md | 74 +++ .../interfaces/LiveQueryStatusFlags.md | 12 +- .../interfaces/LiveQueryWindowBinding.md | 121 +++++ .../interfaces/LiveQueryWindowPageShape.md | 35 ++ .../interfaces/LiveQueryWindowUpdate.md | 90 ++++ .../interfaces/PersistedInitialRenderInput.md | 118 +++++ .../interfaces/PersistedReadinessSource.md | 54 ++- .../type-aliases/LiveQuerySourceIdClaims.md | 13 + .../type-aliases/LiveQueryWindowCollection.md | 48 -- .../type-aliases/LiveQueryWindowInputKind.md | 10 - .../type-aliases/LiveQueryWindowRecompile.md | 19 + .../PersistedReadinessSnapshot.md | 2 +- .../ResolvedLiveQueryWindowInput.md | 28 -- .../variables/PERSISTED_READINESS.md | 2 +- domain_map.yaml | 3 + packages/angular-db/tests/conformance.test.ts | 5 +- packages/db/src/errors.ts | 21 + packages/db/src/index.ts | 6 +- packages/db/src/live-query-adapter.ts | 201 +++++--- packages/db/src/live-query-observer.ts | 2 +- packages/db/src/live-query-options.ts | 57 ++- packages/db/src/live-query-primitives.ts | 74 +++ packages/db/src/live-query-window-binding.ts | 277 +++++++++++ .../db/src/live-query-window-controller.ts | 2 +- packages/db/tests/conformance/contract.ts | 49 +- .../db/tests/conformance/infinite-contract.ts | 13 + .../conformance/infinite-suite-oracle.ts | 107 ++++- packages/db/tests/conformance/suite.ts | 232 +++++++++- packages/db/tests/error-sample-arguments.ts | 1 + packages/db/tests/fixtures/error-codes.json | 3 +- .../db/tests/fixtures/error-messages.json | 5 +- .../tests/fixtures/error-site-messages.json | 7 + packages/preact-db/README.md | 59 +++ packages/preact-db/package.json | 54 +++ packages/preact-db/skills/preact-db/SKILL.md | 151 ++++++ packages/preact-db/src/index.ts | 22 + .../preact-db/src/useLiveInfiniteQuery.ts | 183 ++++++++ packages/preact-db/src/useLiveQuery.ts | 292 ++++++++++++ packages/preact-db/tests/conformance.test.ts | 239 ++++++++++ .../tests/infinite-query-conformance.test.ts | 254 ++++++++++ packages/preact-db/tests/render-probe.ts | 95 ++++ .../tests/useLiveInfiniteQuery.test-d.ts | 59 +++ .../preact-db/tests/useLiveQuery.test-d.ts | 81 ++++ packages/preact-db/tests/useLiveQuery.test.ts | 142 ++++++ packages/preact-db/tsconfig.docs.json | 9 + packages/preact-db/tsconfig.json | 23 + packages/preact-db/vite.config.ts | 23 + packages/react-db/skills/react-db/SKILL.md | 6 +- packages/react-db/src/source-id-bindings.ts | 78 +--- packages/react-db/src/useLiveInfiniteQuery.ts | 368 +++++---------- packages/react-db/src/useLiveQuery.ts | 203 ++++---- packages/react-db/tests/conformance.test.tsx | 35 +- .../tests/infinite-query-conformance.test.tsx | 22 +- ...infinite-query-render-cuts-oracle.test.tsx | 5 +- packages/react-db/tests/rejected-binding.ts | 38 ++ .../tests/source-id-reuse-oracle.test.tsx | 346 ++++++++------ packages/react-db/tests/useLiveQuery.test.tsx | 49 +- .../tests/useLiveSuspenseQuery.test.tsx | 8 +- packages/solid-db/tests/conformance.test.tsx | 5 +- .../src/useLiveInfiniteQuery.svelte.ts | 130 +----- packages/svelte-db/src/useLiveQuery.svelte.ts | 47 +- .../tests/conformance.svelte.test.ts | 5 +- ...riptor-query-release-oracle.svelte.test.ts | 235 +++++++--- .../infinite-query-conformance.svelte.test.ts | 5 +- packages/vue-db/src/useLiveInfiniteQuery.ts | 120 +---- packages/vue-db/tests/conformance.test.ts | 5 +- .../tests/infinite-query-conformance.test.ts | 5 +- pnpm-lock.yaml | 23 + scripts/ci-tests.mjs | 1 + scripts/generate-docs.ts | 7 + skill_spec.md | 1 + 156 files changed, 6092 insertions(+), 1627 deletions(-) create mode 100644 .changeset/db-query-identity-laws.md create mode 100644 .changeset/preact-db-adapter.md create mode 100644 .changeset/react-db-unhashable-throws.md create mode 100644 .changeset/svelte-db-query-identity.md create mode 100644 .changeset/vue-db-window-binding.md create mode 100644 docs/framework/preact/overview.md create mode 100644 docs/framework/preact/reference/functions/useLiveInfiniteQuery.md create mode 100644 docs/framework/preact/reference/functions/useLiveQuery.md create mode 100644 docs/framework/preact/reference/index.md create mode 100644 docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md create mode 100644 docs/framework/preact/reference/interfaces/UseLiveQueryResult.md create mode 100644 docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md create mode 100644 docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md create mode 100644 docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md create mode 100644 docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md create mode 100644 docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md create mode 100644 docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md create mode 100644 docs/reference/classes/SourceCollectionIdReuseError.md delete mode 100644 docs/reference/functions/assertLiveQueryWindowManyResult.md create mode 100644 docs/reference/functions/bindLiveQueryWindow.md create mode 100644 docs/reference/functions/claimLiveQuerySourceIds.md delete mode 100644 docs/reference/functions/compareLiveQueryWindowDependencies.md create mode 100644 docs/reference/functions/createLiveQuerySourceIdClaims.md create mode 100644 docs/reference/functions/getLiveQueryKey.md delete mode 100644 docs/reference/functions/getLiveQueryWindowCollectionWarning.md delete mode 100644 docs/reference/functions/getLiveQueryWindowInputKind.md create mode 100644 docs/reference/functions/getSourceObjectToken.md delete mode 100644 docs/reference/functions/hasLiveQueryWindowLeases.md create mode 100644 docs/reference/functions/identifyLiveQuery.md delete mode 100644 docs/reference/functions/isLiveQueryWindowCollection.md delete mode 100644 docs/reference/functions/liveQueryWindowMatches.md delete mode 100644 docs/reference/functions/normalizeLiveQueryWindowPageSize.md create mode 100644 docs/reference/functions/readLiveQueryWindowPageShape.md delete mode 100644 docs/reference/functions/resolveLiveQueryWindowInput.md delete mode 100644 docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md create mode 100644 docs/reference/functions/waitForPersistedInitialRender.md create mode 100644 docs/reference/interfaces/LiveQueryIdentityScope.md create mode 100644 docs/reference/interfaces/LiveQueryWindowBinding.md create mode 100644 docs/reference/interfaces/LiveQueryWindowPageShape.md create mode 100644 docs/reference/interfaces/LiveQueryWindowUpdate.md create mode 100644 docs/reference/interfaces/PersistedInitialRenderInput.md create mode 100644 docs/reference/type-aliases/LiveQuerySourceIdClaims.md delete mode 100644 docs/reference/type-aliases/LiveQueryWindowCollection.md delete mode 100644 docs/reference/type-aliases/LiveQueryWindowInputKind.md create mode 100644 docs/reference/type-aliases/LiveQueryWindowRecompile.md delete mode 100644 docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md create mode 100644 packages/db/src/live-query-primitives.ts create mode 100644 packages/db/src/live-query-window-binding.ts create mode 100644 packages/preact-db/README.md create mode 100644 packages/preact-db/package.json create mode 100644 packages/preact-db/skills/preact-db/SKILL.md create mode 100644 packages/preact-db/src/index.ts create mode 100644 packages/preact-db/src/useLiveInfiniteQuery.ts create mode 100644 packages/preact-db/src/useLiveQuery.ts create mode 100644 packages/preact-db/tests/conformance.test.ts create mode 100644 packages/preact-db/tests/infinite-query-conformance.test.ts create mode 100644 packages/preact-db/tests/render-probe.ts create mode 100644 packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts create mode 100644 packages/preact-db/tests/useLiveQuery.test-d.ts create mode 100644 packages/preact-db/tests/useLiveQuery.test.ts create mode 100644 packages/preact-db/tsconfig.docs.json create mode 100644 packages/preact-db/tsconfig.json create mode 100644 packages/preact-db/vite.config.ts create mode 100644 packages/react-db/tests/rejected-binding.ts diff --git a/.changeset/db-query-identity-laws.md b/.changeset/db-query-identity-laws.md new file mode 100644 index 0000000000..56cb2925b0 --- /dev/null +++ b/.changeset/db-query-identity-laws.md @@ -0,0 +1,9 @@ +--- +'@tanstack/db': minor +--- + +Collect the helpers the framework adapters share into one adapter layer, exported from `@tanstack/db` and marked unstable for external use. It adds `identifyLiveQuery`, which checks a prepared query's identity and source Collection IDs for a hook that compares query identity, and `bindLiveQueryWindow`, the window binding every `useLiveInfiniteQuery` now uses. The window helpers that binding replaces are no longer exported. + +Add `SourceCollectionIdReuseError`, which a mounted hook that compares query identity throws when a different source Collection appears under a Collection ID it already read. + +`prepareLiveQueryValue` now releases the sync starts it deferred when preparation throws, such as when a collection descriptor's factory fails. Each released source follows its own start policy, so `startSyncImmediate()` or another hook can still start it. diff --git a/.changeset/preact-db-adapter.md b/.changeset/preact-db-adapter.md new file mode 100644 index 0000000000..d9429c4be5 --- /dev/null +++ b/.changeset/preact-db-adapter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/preact-db': minor +--- + +Add `@tanstack/preact-db`, a Preact adapter with `useLiveQuery` and `useLiveInfiniteQuery`. Both compare query identity derived from the query IR on each render, require a `queryKey` for functional query variants, and pass the shared live-query and infinite-query conformance suites. Queries read concrete source Collections; `DbProvider` and collection descriptors are not supported yet. diff --git a/.changeset/react-db-unhashable-throws.md b/.changeset/react-db-unhashable-throws.md new file mode 100644 index 0000000000..840a6f3d0c --- /dev/null +++ b/.changeset/react-db-unhashable-throws.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-db': minor +--- + +**Breaking (1.0):** a live query whose IR has no stable identity, such as one using `fn.where`, `fn.select`, or `fn.having`, now throws `UnhashableQueryIRError` unless it supplies a nonempty `queryKey`. It previously warned in development and kept its first compilation. An empty `queryKey` now counts as none, as it already did for a `DbClient` preload, so a hook and a preload agree on the query's identity. A dependency array does not replace `queryKey`: in that mode the hook checks identity and claims source Collection IDs each time the query compiles. A reused source Collection ID now throws `SourceCollectionIdReuseError`. + +A rejected render now releases every sync start it deferred for a `DbClient` descriptor, whatever rejected it, so a later hook or `startSyncImmediate()` can start that source. diff --git a/.changeset/svelte-db-query-identity.md b/.changeset/svelte-db-query-identity.md new file mode 100644 index 0000000000..d3fae99c2e --- /dev/null +++ b/.changeset/svelte-db-query-identity.md @@ -0,0 +1,7 @@ +--- +'@tanstack/svelte-db': minor +--- + +**Breaking (1.0):** a `useLiveQuery` backed by a `DbClient` matches queries by identity hash, so it now throws `UnhashableQueryIRError` for a query without a stable IR identity unless it supplies a nonempty `queryKey`. It previously compiled the query without a hydration key. While such a hook is mounted, a different source Collection under a Collection ID it already read throws `SourceCollectionIdReuseError`, and every result property then throws instead of returning the previous query's state. Queries without a `DbClient` are unchanged. + +A rejected client-backed query now releases every sync start it deferred, so `startSyncImmediate()` or another hook can still start that source. `useLiveInfiniteQuery` now uses the shared window binding from `@tanstack/db`. diff --git a/.changeset/vue-db-window-binding.md b/.changeset/vue-db-window-binding.md new file mode 100644 index 0000000000..136a0feb96 --- /dev/null +++ b/.changeset/vue-db-window-binding.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-db': patch +--- + +`useLiveInfiniteQuery` now uses the shared window binding from `@tanstack/db`. A window collection it creates starts syncing when the query compiles, as in the other adapters, and an update that changes nothing keeps the current controller. diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml index abbfac7dfa..62a0098ea5 100644 --- a/_artifacts/skill_tree.yaml +++ b/_artifacts/skill_tree.yaml @@ -163,6 +163,23 @@ skills: - 'TanStack/db:packages/react-db/src/useLiveInfiniteQuery.ts' - 'TanStack/db:packages/react-db/src/usePacedMutations.ts' + - name: 'Preact DB' + slug: 'preact-db' + type: 'framework' + domain: 'framework-integration' + path: 'skills/preact-db/SKILL.md' + package: 'packages/preact-db' + description: > + Preact bindings for TanStack DB. useLiveQuery and useLiveInfiniteQuery + hooks with identity derived from query IR; functional variants need + queryKey. + requires: + - 'db-core' + sources: + - 'TanStack/db:docs/framework/preact/overview.md' + - 'TanStack/db:packages/preact-db/src/useLiveQuery.ts' + - 'TanStack/db:packages/preact-db/src/useLiveInfiniteQuery.ts' + - name: 'Vue DB' slug: 'vue-db' type: 'framework' diff --git a/docs/config.json b/docs/config.json index 14ce831d6a..9fd5a3fb5a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -108,6 +108,10 @@ "label": "React", "to": "framework/react/overview" }, + { + "label": "Preact", + "to": "framework/preact/overview" + }, { "label": "Vue", "to": "framework/vue/overview" @@ -221,6 +225,23 @@ } ] }, + { + "label": "preact", + "children": [ + { + "label": "Preact Hooks", + "to": "framework/preact/reference/index" + }, + { + "label": "useLiveQuery", + "to": "framework/preact/reference/functions/useLiveQuery" + }, + { + "label": "useLiveInfiniteQuery", + "to": "framework/preact/reference/functions/useLiveInfiniteQuery" + } + ] + }, { "label": "solid", "children": [ diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index e45217b638..898d5a1566 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -19,6 +19,10 @@ production queues, caches, or semantic helpers merely to share their names. | Collection | The public keyed data container. Capitalize it when referring to the TanStack DB type. | Relation, table, or query result. | | source Collection | A Collection read by a query or adapter. | Source relation when the value is a public Collection. | | live-query Collection | A Collection whose rows are produced by a live query. | Query, observer, or result set. | +| framework binding | What one mounted framework hook or injector keeps between updates: its compiled live query or window binding, its observer or controller, and its source Collection ID claims. | Subscription, observer, or live-query Collection. | +| query identity | The value that decides whether a framework binding compiles its query input again: a nonempty `queryKey`, or the identity derived from the query IR. A `DbClient` matches preloaded and hydrated queries by its hash. React and Preact compare it on every render; reactive adapters compile again when their reads change and compare none. | Query hash when the value is not hashed, or dependency list. | +| source Collection ID claim | A framework binding's record of which source Collection object each Collection ID named, per `DbClient` scope, kept while the binding is mounted. A binding that compares query identity rejects a different object under a claimed ID. | Collection registry or source identity. | +| window binding | The infinite-query state a framework binding keeps between updates: the input, its identity dependencies, the page shape, the window collection, and its controller. `bindLiveQueryWindow` decides whether to keep it, replace its controller, or bind a new window collection. | Window controller or live-query Collection. | | pooled live query | A live query on one source Collection whose `where` has at least one `eq(field, literal)` conjunct and otherwise reads only the row, optionally ordered by the row's own fields without a limit, served from an equality partition; its live-query Collection is built only when read. | Cached query or shared live-query Collection. | | equality partition | Source rows grouped by the `eq`-normalized values of one set of fields, shared by pooled live queries on the same source Collection with the same equality fields and order terms, including resolved comparison options. | Index or bucket relation. | | partition group | The rows of an equality partition whose fields equal one tuple of literals. | Bucket or active bucket. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 5afacbd874..cdc67ee511 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,9 +641,9 @@ 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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | +| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`; a new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows; React and Preact drivers rethrow the rejection on later reads because the failed hook is unmounted. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses three causes (source-ID collision, unhashable IR, a descriptor factory that throws) with four hook paths and two direct readers; Svelte crosses the same causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Removing either release point, or routing Svelte's getters around the compiled query, fails the matching cells; `preload()` overrides a deferral, so those cells pass without the release by design. Adapter-specific `queryKey` change semantics stay with each adapter's own tests. 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, Preact, 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. | | 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. | +| 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.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. | | Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also pins that objects of another class differ, that plain and null-prototype objects are one class, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | | Mixed-kind ordered prefixes | [mixed-kind public-order oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/mixed-index-order-oracle.test.ts) | A separate unindexed ready Collection computes the public ordered two-row prefix for the same keyed rows as an eager BasicIndex or BTreeIndex Collection. Five histories cross number/bigint, boolean/number, object/string, and array/object values, with insert, update, and delete transitions into and out of mixed domains. Ascending and descending reads use one ascending physical index, so descending checks exercise ReverseIndex. After preload and after both source copies commit each step, exact direct and live-query IDs must match the scan. Same-kind checkpoints verify that safe indexes still serve ordered reads. The original type-name index order failed eight ascending public-prefix checks. A second numeric source Map models a finite on-demand provider: a bigint enters and leaves the visible prefix, deletion requires an uninstalled row, and an offset window reaches a cursor after the retained BTree index loses ordered-plan eligibility. Public IDs match the independent provider order at each settled window; controls withholding either requested row fail those assertions. This owner does not define a global precedence among unlike values, cover in-place value mutation or arbitrary mixed-kind pairs, or prove that every retained on-demand traversal is safe after its source domain changes. | diff --git a/docs/errors.md b/docs/errors.md index fcfdcce0c4..784bb6cbcb 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -990,6 +990,16 @@ Development builds show the full message. For example: setWindow() cannot change the limit of a findOne() query. ``` + + +## Error 245: `SourceCollectionIdReuseError` + +Development builds show the full message. For example: + +```text +[useLiveQuery] Collection ID "todos" named a different source Collection than one this live query already read. While the hook is mounted, each Collection ID must keep naming one source Collection. Unmount the hook and clean up its previous source and client scope before reusing the ID. +``` + ## Other errors and log messages These are plain `Error`, `TypeError`, `RangeError`, or @@ -2469,3 +2479,13 @@ Development builds show this message, with `${...}` replaced by values: ```text A windowed or findOne result row has no include route ``` + + + +## Error 246: `live-query-window-binding.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination. +``` diff --git a/docs/framework/preact/overview.md b/docs/framework/preact/overview.md new file mode 100644 index 0000000000..85d2afb909 --- /dev/null +++ b/docs/framework/preact/overview.md @@ -0,0 +1,154 @@ +--- +title: TanStack DB Preact Adapter +id: adapter +--- + +## Installation + +```sh +npm install @tanstack/preact-db +``` + +`@tanstack/preact-db` supports Preact 10 and later and ships as ES modules. + +Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration. + +## Preact Hooks + +See the [Preact Functions Reference](./reference/index.md) to see the full list of hooks available in the Preact Adapter. + +For comprehensive documentation on writing queries (filtering, joins, aggregations, etc.), see the [Live Queries Guide](../../guides/live-queries). + +## Basic Usage + +### useLiveQuery + +The `useLiveQuery` hook creates a live query that automatically updates your component when data changes: + +```tsx +import { eq, useLiveQuery } from '@tanstack/preact-db' + +function TodoList() { + const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })), + ) + + if (isLoading) return
Loading...
+ + return ( + + ) +} +``` + +`useLiveQuery` accepts a query callback, a `{ query }` config object, or an existing live-query Collection. Return `undefined` or `null` from the callback to disable the query; a disabled query reports `status: 'disabled'` and has no `data` or `state`: + +```tsx +function UserTodos({ userId }: { userId?: string }) { + const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, + ) + + if (!isEnabled) return
Select a user
+ return
{data?.length} todos
+} +``` + +### Query Identity + +Preact live query hooks derive the live query identity from structured query IR. The hook runs the query builder on each render, normalizes the resulting IR, and uses that as the identity. When the derived identity changes, the old live query collection is cleaned up and a new one is created. + +That means normal structured queries need no `queryKey` and no dependency array. Captured values inside structured expressions become part of the derived identity: + +```tsx +function FilteredTodos({ minPriority }: { minPriority: number }) { + const { data } = useLiveQuery((q) => + q + .from({ todos: todoCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + ) + + return
{data.length} high-priority todos
+} +``` + +While a hook is mounted, each source Collection ID its queries read must keep referring to the same Collection object. If a render supplies a different source object with a previously used ID, the hook throws `SourceCollectionIdReuseError` instead of returning rows from the old source. To reuse an ID, unmount the hook, clean up its source, then mount the replacement. Separate hooks can use the same ID. + +#### When to Use Query Keys + +Use `queryKey` when DB cannot derive identity from structured IR. The common case is a functional query variant such as `.fn.where`, `.fn.select`, or `.fn.having`. Without a `queryKey`, such a query throws `UnhashableQueryIRError`: + +```tsx +function SearchTodos({ search }: { search: string }) { + const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q + .from({ todos: todoCollection }) + .fn.where(({ todos }) => + todos.text.toLowerCase().includes(search.toLowerCase()), + ), + }) + + return
{data.length} matching todos
+} +``` + +An explicit `queryKey` is the caller's identity contract. Represent every value the opaque logic captures in the key, and change the key when its source object changes, even if the source ID stays the same. Use a primitive scope token in the key; Collection objects cannot be hashed there. + +#### What Happens When Identity Changes + +When the derived identity or explicit query key changes: + +1. The previous live query collection is cleaned up +2. A new query is created with the updated values +3. The component re-renders with the new data + +### useLiveInfiniteQuery + +For paginated data with live updates, use `useLiveInfiniteQuery`: + +```tsx +const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ posts: postsCollection }) + .where(({ posts }) => eq(posts.category, category)) + .orderBy(({ posts }) => posts.createdAt, 'desc'), + { + pageSize: 20, + }, +) +``` + +The query must include `.orderBy()`. The hook keeps one live query collection and widens its window as pages load, so every loaded page stays live. Its identity follows `useLiveQuery`: pass `queryKey` in the config for a functional query variant. + +`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. + +This hook widens an ordered live query; it is not TanStack Query's `useInfiniteQuery`. `getNextPageParam` is rejected. `initialPageParam` only labels the returned pages; it does not set a server cursor or skip remote rows. For server pagination, use an on-demand Query Collection whose `queryFn` fulfills `meta.loadSubsetOptions`. See the [server pagination guide](../../collections/query-collection.md#server-pagination-with-live-queries). + +### Using Pre-created Collections + +Pass an existing live query collection to share one query across components. The hook starts its sync and leaves its retention policy to the caller: + +```tsx +const activeTodos = createLiveQueryCollection((q) => + q.from({ todos: todoCollection }).where(({ todos }) => eq(todos.active, true)), +) + +function ActiveTodos() { + const { data, collection } = useLiveQuery(activeTodos) + return
{data.length} active todos
+} +``` diff --git a/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md b/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md new file mode 100644 index 0000000000..82cbf0adee --- /dev/null +++ b/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md @@ -0,0 +1,106 @@ +--- +id: useLiveInfiniteQuery +title: useLiveInfiniteQuery +--- + +## Call Signature + +```ts +function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; +``` + +Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L85) + +Page through an ordered live query, growing one live-query Collection's +window with `utils.setWindow()` instead of recreating it per page. + +The query must include `.orderBy()`. Its identity follows `useLiveQuery`: +derived from the query IR, or named by `queryKey`. A query whose IR cannot be +hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +#### config + +[`UseLiveInfiniteQueryConfig`](../type-aliases/UseLiveInfiniteQueryConfig.md)\<`any`\> + +### Returns + +[`UseLiveInfiniteQueryReturnWithCollection`](../type-aliases/UseLiveInfiniteQueryReturnWithCollection.md)\<`TResult`, `TKey`, `TUtils`\> + +### Example + +```ts +const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, `desc`), + { pageSize: 20 }, +) +``` + +## Call Signature + +```ts +function useLiveInfiniteQuery(queryFn, config): UseLiveInfiniteQueryReturn; +``` + +Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L94) + +Page through an ordered live query, growing one live-query Collection's +window with `utils.setWindow()` instead of recreating it per page. + +The query must include `.orderBy()`. Its identity follows `useLiveQuery`: +derived from the query IR, or named by `queryKey`. A query whose IR cannot be +hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +#### config + +[`UseLiveInfiniteQueryConfig`](../type-aliases/UseLiveInfiniteQueryConfig.md)\<`TContext`\> + +### Returns + +[`UseLiveInfiniteQueryReturn`](../type-aliases/UseLiveInfiniteQueryReturn.md)\<`TContext`\> + +### Example + +```ts +const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, `desc`), + { pageSize: 20 }, +) +``` diff --git a/docs/framework/preact/reference/functions/useLiveQuery.md b/docs/framework/preact/reference/functions/useLiveQuery.md new file mode 100644 index 0000000000..9542b7a160 --- /dev/null +++ b/docs/framework/preact/reference/functions/useLiveQuery.md @@ -0,0 +1,435 @@ +--- +id: useLiveQuery +title: useLiveQuery +--- + +## Call Signature + +```ts +function useLiveQuery(queryFn): QueryResult; +``` + +Defined in: [useLiveQuery.ts:147](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L147) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +### Returns + +`QueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn): ConditionalQueryResult; +``` + +Defined in: [useLiveQuery.ts:151](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L151) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> \| `null` \| `undefined` + +### Returns + +`ConditionalQueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn): ConditionalQueryResult; +``` + +Defined in: [useLiveQuery.ts:157](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L157) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => + \| `LiveQueryCollectionConfig`\<`TContext`, `RootQueryResult`\<`TContext`\>\> + \| `null` + \| `undefined` + +### Returns + +`ConditionalQueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn): UseConditionalLiveQueryResult, TResult>>; +``` + +Defined in: [useLiveQuery.ts:163](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L163) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### queryFn + +(`q`) => + \| `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> + \| `null` + \| `undefined` + +### Returns + +[`UseConditionalLiveQueryResult`](../interfaces/UseConditionalLiveQueryResult.md)\<`TResult`, `TKey`, `TResult`[], `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\>\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(config): QueryResult; +``` + +Defined in: [useLiveQuery.ts:187](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L187) + +Subscribe to a live query described by a config object. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +[`UseLiveQueryConfig`](../type-aliases/UseLiveQueryConfig.md)\<`TContext`\> + +### Returns + +`QueryResult`\<`TContext`\> + +### Example + +```ts +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todosCollection }), + gcTime: 60_000, +}) +``` + +## Call Signature + +```ts +function useLiveQuery(config): ConditionalQueryResult; +``` + +Defined in: [useLiveQuery.ts:191](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L191) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +[`ConditionalUseLiveQueryConfig`](../type-aliases/ConditionalUseLiveQueryConfig.md)\<`TContext`\> + +### Returns + +`ConditionalQueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(liveQueryCollection): UseLiveQueryResult, TResult>>; +``` + +Defined in: [useLiveQuery.ts:205](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L205) + +Subscribe to an existing live-query Collection. The hook starts its sync and +leaves its retention policy to the caller. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +### Returns + +[`UseLiveQueryResult`](../interfaces/UseLiveQueryResult.md)\<`TResult`, `TKey`, `TResult`[], `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\>\> + +### Example + +```ts +const activeTodos = createLiveQueryCollection((q) => + q.from({ todos: todosCollection }).where(({ todos }) => todos.active), +) +const { data, collection } = useLiveQuery(activeTodos) +``` + +## Call Signature + +```ts +function useLiveQuery(liveQueryCollection): UseLiveQueryResult, TResult> & SingleResult>; +``` + +Defined in: [useLiveQuery.ts:218](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L218) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `SingleResult` + +### Returns + +[`UseLiveQueryResult`](../interfaces/UseLiveQueryResult.md)\<`TResult`, `TKey`, `TResult` \| `undefined`, `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `SingleResult`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` diff --git a/docs/framework/preact/reference/index.md b/docs/framework/preact/reference/index.md new file mode 100644 index 0000000000..6c7bc6c787 --- /dev/null +++ b/docs/framework/preact/reference/index.md @@ -0,0 +1,23 @@ +--- +id: "@tanstack/preact-db" +title: "@tanstack/preact-db" +--- + +## Interfaces + +- [UseConditionalLiveQueryResult](interfaces/UseConditionalLiveQueryResult.md) +- [UseLiveQueryResult](interfaces/UseLiveQueryResult.md) + +## Type Aliases + +- [ConditionalUseLiveQueryConfig](type-aliases/ConditionalUseLiveQueryConfig.md) +- [UseLiveInfiniteQueryConfig](type-aliases/UseLiveInfiniteQueryConfig.md) +- [UseLiveInfiniteQueryReturn](type-aliases/UseLiveInfiniteQueryReturn.md) +- [UseLiveInfiniteQueryReturnWithCollection](type-aliases/UseLiveInfiniteQueryReturnWithCollection.md) +- [UseLiveQueryConfig](type-aliases/UseLiveQueryConfig.md) +- [UseLiveQueryStatus](type-aliases/UseLiveQueryStatus.md) + +## Functions + +- [useLiveInfiniteQuery](functions/useLiveInfiniteQuery.md) +- [useLiveQuery](functions/useLiveQuery.md) diff --git a/docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md b/docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md new file mode 100644 index 0000000000..5f52579be5 --- /dev/null +++ b/docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md @@ -0,0 +1,208 @@ +--- +id: UseConditionalLiveQueryResult +title: UseConditionalLiveQueryResult +--- + +Defined in: [useLiveQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L91) + +The result of a live query whose callback can disable it. + +## Extends + +- `LiveQueryResultFlags` + +## Type Parameters + +### TResult + +`TResult` *extends* `object` + +### TKey + +`TKey` *extends* `string` \| `number` + +### TData + +`TData` + +### TCollection + +`TCollection` + +## Properties + +### collection + +```ts +collection: TCollection | undefined; +``` + +Defined in: [useLiveQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L99) + +*** + +### data + +```ts +data: TData | undefined; +``` + +Defined in: [useLiveQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L98) + +*** + +### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +Defined in: [useLiveQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L73) + +#### Inherited from + +```ts +LiveQueryResultFlags.isCleanedUp +``` + +*** + +### isEnabled + +```ts +isEnabled: boolean; +``` + +Defined in: [useLiveQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L101) + +*** + +### isError + +```ts +isError: boolean; +``` + +Defined in: [useLiveQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L72) + +#### Inherited from + +```ts +LiveQueryResultFlags.isError +``` + +*** + +### isIdle + +```ts +isIdle: boolean; +``` + +Defined in: [useLiveQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L71) + +#### Inherited from + +```ts +LiveQueryResultFlags.isIdle +``` + +*** + +### isLoading + +```ts +isLoading: boolean; +``` + +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L66) + +#### Inherited from + +```ts +LiveQueryResultFlags.isLoading +``` + +*** + +### isPersistedReady + +```ts +isPersistedReady: boolean; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +```ts +LiveQueryResultFlags.isPersistedReady +``` + +*** + +### isReady + +```ts +isReady: boolean; +``` + +Defined in: [useLiveQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L67) + +#### Inherited from + +```ts +LiveQueryResultFlags.isReady +``` + +*** + +### persistedError + +```ts +persistedError: unknown; +``` + +Defined in: [useLiveQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L70) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedError +``` + +*** + +### persistedStatus + +```ts +persistedStatus: LiveQueryPersistedStatus; +``` + +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L68) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedStatus +``` + +*** + +### state + +```ts +state: ReadonlyMap | undefined; +``` + +Defined in: [useLiveQuery.ts:97](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L97) + +*** + +### status + +```ts +status: UseLiveQueryStatus; +``` + +Defined in: [useLiveQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L100) diff --git a/docs/framework/preact/reference/interfaces/UseLiveQueryResult.md b/docs/framework/preact/reference/interfaces/UseLiveQueryResult.md new file mode 100644 index 0000000000..49c2c10762 --- /dev/null +++ b/docs/framework/preact/reference/interfaces/UseLiveQueryResult.md @@ -0,0 +1,208 @@ +--- +id: UseLiveQueryResult +title: UseLiveQueryResult +--- + +Defined in: [useLiveQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L77) + +The result of a live query that is always enabled. + +## Extends + +- `LiveQueryResultFlags` + +## Type Parameters + +### TResult + +`TResult` *extends* `object` + +### TKey + +`TKey` *extends* `string` \| `number` + +### TData + +`TData` + +### TCollection + +`TCollection` + +## Properties + +### collection + +```ts +collection: TCollection; +``` + +Defined in: [useLiveQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L85) + +*** + +### data + +```ts +data: TData; +``` + +Defined in: [useLiveQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L84) + +*** + +### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +Defined in: [useLiveQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L73) + +#### Inherited from + +```ts +LiveQueryResultFlags.isCleanedUp +``` + +*** + +### isEnabled + +```ts +isEnabled: true; +``` + +Defined in: [useLiveQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L87) + +*** + +### isError + +```ts +isError: boolean; +``` + +Defined in: [useLiveQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L72) + +#### Inherited from + +```ts +LiveQueryResultFlags.isError +``` + +*** + +### isIdle + +```ts +isIdle: boolean; +``` + +Defined in: [useLiveQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L71) + +#### Inherited from + +```ts +LiveQueryResultFlags.isIdle +``` + +*** + +### isLoading + +```ts +isLoading: boolean; +``` + +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L66) + +#### Inherited from + +```ts +LiveQueryResultFlags.isLoading +``` + +*** + +### isPersistedReady + +```ts +isPersistedReady: boolean; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +```ts +LiveQueryResultFlags.isPersistedReady +``` + +*** + +### isReady + +```ts +isReady: boolean; +``` + +Defined in: [useLiveQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L67) + +#### Inherited from + +```ts +LiveQueryResultFlags.isReady +``` + +*** + +### persistedError + +```ts +persistedError: unknown; +``` + +Defined in: [useLiveQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L70) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedError +``` + +*** + +### persistedStatus + +```ts +persistedStatus: LiveQueryPersistedStatus; +``` + +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L68) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedStatus +``` + +*** + +### state + +```ts +state: ReadonlyMap; +``` + +Defined in: [useLiveQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L83) + +*** + +### status + +```ts +status: CollectionStatus; +``` + +Defined in: [useLiveQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L86) diff --git a/docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md b/docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md new file mode 100644 index 0000000000..203d6ccebf --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md @@ -0,0 +1,26 @@ +--- +id: ConditionalUseLiveQueryConfig +title: ConditionalUseLiveQueryConfig +--- + +```ts +type ConditionalUseLiveQueryConfig = UseLiveQueryConfigOptions & object; +``` + +Defined in: [useLiveQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L56) + +## Type Declaration + +### query + +```ts +query: + | ConfiguredQueryBuilder + | (q) => ConfiguredQueryBuilder | undefined | null; +``` + +## Type Parameters + +### TContext + +`TContext` *extends* `Context` diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md new file mode 100644 index 0000000000..4c4b0cc19b --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -0,0 +1,51 @@ +--- +id: UseLiveInfiniteQueryConfig +title: UseLiveInfiniteQueryConfig +--- + +```ts +type UseLiveInfiniteQueryConfig<_TContext> = object; +``` + +Defined in: [useLiveInfiniteQuery.ts:26](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L26) + +## Type Parameters + +### _TContext + +`_TContext` *extends* `Context` + +## Properties + +### initialPageParam? + +```ts +optional initialPageParam: number; +``` + +Defined in: [useLiveInfiniteQuery.ts:34](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L34) + +First result-page label, not a server cursor or remote offset. + +*** + +### pageSize? + +```ts +optional pageSize: number; +``` + +Defined in: [useLiveInfiniteQuery.ts:32](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L32) + +*** + +### queryKey? + +```ts +optional queryKey: LiveQueryKey; +``` + +Defined in: [useLiveInfiniteQuery.ts:31](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L31) + +Explicit identity for a query whose IR cannot derive one, such as a query +with functional variants. Structured queries should omit it. diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md new file mode 100644 index 0000000000..1c0883646e --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -0,0 +1,17 @@ +--- +id: UseLiveInfiniteQueryReturn +title: UseLiveInfiniteQueryReturn +--- + +```ts +type UseLiveInfiniteQueryReturn = UseLiveQueryResult[number], string | number, InferResultType, Collection[number], string | number, { +}>> & InfiniteQueryPagination[number]>; +``` + +Defined in: [useLiveInfiniteQuery.ts:46](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L46) + +## Type Parameters + +### TContext + +`TContext` *extends* `Context` diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md new file mode 100644 index 0000000000..f54fb42677 --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -0,0 +1,24 @@ +--- +id: UseLiveInfiniteQueryReturnWithCollection +title: UseLiveInfiniteQueryReturnWithCollection +--- + +```ts +type UseLiveInfiniteQueryReturnWithCollection = UseLiveQueryResult & NonSingleResult> & InfiniteQueryPagination; +``` + +Defined in: [useLiveInfiniteQuery.ts:55](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L55) + +## Type Parameters + +### TResult + +`TResult` *extends* `object` + +### TKey + +`TKey` *extends* `string` \| `number` + +### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> diff --git a/docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md new file mode 100644 index 0000000000..3b70c0b69b --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md @@ -0,0 +1,16 @@ +--- +id: UseLiveQueryConfig +title: UseLiveQueryConfig +--- + +```ts +type UseLiveQueryConfig = UseLiveQueryConfigOptions & Pick, "query">; +``` + +Defined in: [useLiveQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L52) + +## Type Parameters + +### TContext + +`TContext` *extends* `Context` diff --git a/docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md b/docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md new file mode 100644 index 0000000000..a363085f99 --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md @@ -0,0 +1,10 @@ +--- +id: UseLiveQueryStatus +title: UseLiveQueryStatus +--- + +```ts +type UseLiveQueryStatus = CollectionStatus | "disabled"; +``` + +Defined in: [useLiveQuery.ts:34](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L34) diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 484dd6b56d..0e0b773ff2 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -73,10 +73,11 @@ function FilteredTodos({ minPriority }: { minPriority: number }) { } ``` -For derived-identity queries in one mounted hook and `DbClient` scope, a source -Collection ID must keep referring to the same Collection object. If a render -supplies a different source object with a previously used ID, the hook throws -instead of returning rows from the old source. To reuse an ID within that hook, +For queries without `queryKey` in one mounted hook and `DbClient` scope, a +source Collection ID must keep referring to the same Collection object. If a +render supplies a different source object with a previously used ID, the hook +throws `SourceCollectionIdReuseError` instead of returning rows from the old +source. To reuse an ID within that hook, unmount it, clean up its source and client scope, then mount the replacement. Separate hooks and independent client scopes can use the same ID. @@ -112,10 +113,10 @@ An explicit `queryKey` is the caller's identity contract. If its source object changes, change the key too, even when the source ID stays the same. Use a primitive scope token in the key; Collection objects cannot be hashed there. -Before 1.0, an unhashable query warns in development and keeps its legacy -mount-stable identity. The query still runs, but captured values inside opaque -logic only become reactive when they are represented in `queryKey`. In 1.0, an -unhashable query without `queryKey` will throw. If deriving identity becomes +An unhashable query without `queryKey` throws `UnhashableQueryIRError`, so +represent the values that opaque logic captures in the key. An empty +`queryKey` counts as none, as it does for a `DbClient` preload. A dependency +array does not replace `queryKey` for such a query. If deriving identity becomes expensive across renders, the hook warns once and suggests adding a `queryKey` as a performance escape hatch. diff --git a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md index 29a55ad3f8..a946b5459a 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:124](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L124) 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:134](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L134) 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..be9b9aae38 100644 --- a/docs/framework/react/reference/functions/useLiveQuery.md +++ b/docs/framework/react/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:387](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L387) +Defined in: [useLiveQuery.ts:381](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L381) Create a live query using a query function. @@ -196,7 +196,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:407](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L407) +Defined in: [useLiveQuery.ts:401](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L401) Create a live query using a query function. @@ -387,7 +387,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:429](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L429) +Defined in: [useLiveQuery.ts:423](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L423) Create a live query using a query function. @@ -581,7 +581,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:451](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L451) +Defined in: [useLiveQuery.ts:445](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L445) Create a live query using a query function. @@ -780,7 +780,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:477](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L477) +Defined in: [useLiveQuery.ts:471](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L471) Create a live query using a query function. @@ -990,7 +990,7 @@ return ( function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:548](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L548) +Defined in: [useLiveQuery.ts:542](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L542) Create a live query using configuration object @@ -1134,7 +1134,7 @@ return
{data.length} items loaded
function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:567](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L567) +Defined in: [useLiveQuery.ts:561](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L561) Create a live query using a query function. @@ -1317,7 +1317,7 @@ return ( function useLiveQuery(config, deps?): object; ``` -Defined in: [useLiveQuery.ts:586](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L586) +Defined in: [useLiveQuery.ts:580](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L580) Create a live query using a query function. @@ -1502,7 +1502,7 @@ return ( function useLiveQuery(config, deps): object; ``` -Defined in: [useLiveQuery.ts:606](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L606) +Defined in: [useLiveQuery.ts:600](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L600) Create a live query using a query function. @@ -1691,7 +1691,7 @@ return ( function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:655](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L655) +Defined in: [useLiveQuery.ts:649](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L649) Subscribe to an existing live query collection @@ -1837,7 +1837,7 @@ return
{data.map(item => )}
function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:678](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L678) +Defined in: [useLiveQuery.ts:672](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L672) Create a live query using a query function. diff --git a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md index 1926ba2bb6..cc7fc0145e 100644 --- a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryConfig type ConditionalUseLiveQueryConfig = UseLiveQueryConfigOptions & object; ``` -Defined in: [useLiveQuery.ts:133](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L133) +Defined in: [useLiveQuery.ts:135](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L135) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/LiveQueryKey.md b/docs/framework/react/reference/type-aliases/LiveQueryKey.md index bfce3663b8..3621a59f4c 100644 --- a/docs/framework/react/reference/type-aliases/LiveQueryKey.md +++ b/docs/framework/react/reference/type-aliases/LiveQueryKey.md @@ -7,4 +7,4 @@ title: LiveQueryKey type LiveQueryKey = ReadonlyArray; ``` -Defined in: [useLiveQuery.ts:109](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L109) +Defined in: [useLiveQuery.ts:111](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L111) diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md index 72f939f102..47dc4902ed 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig<_TContext> = object; ``` -Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L54) +Defined in: [useLiveInfiniteQuery.ts:43](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L43) ## Type Parameters @@ -23,7 +23,7 @@ Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/mai optional client: DbClient; ``` -Defined in: [useLiveInfiniteQuery.ts:62](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L62) +Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L51) Override the nearest DbProvider for this query. @@ -35,7 +35,7 @@ Override the nearest DbProvider for this query. optional initialPageParam: number; ``` -Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L65) +Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L54) First result-page label, not a server cursor or remote offset. @@ -47,7 +47,7 @@ First result-page label, not a server cursor or remote offset. optional pageSize: number; ``` -Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L63) +Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L52) *** @@ -57,7 +57,7 @@ Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/mai optional queryKey: LiveQueryKey; ``` -Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L60) +Defined in: [useLiveInfiniteQuery.ts:49](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L49) Explicit identity for queries that contain opaque functional variants or are hot enough that deriving identity from structured IR is too expensive. diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md index a4501cd291..5fad4c99d7 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturn type UseLiveInfiniteQueryReturn = Omit, "data"> & object; ``` -Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L68) +Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L57) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index ffe9403754..397ec7b889 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:70](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L70) ## 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:77](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L77) *** @@ -41,7 +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) +Defined in: [useLiveInfiniteQuery.ts:75](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L75) *** @@ -51,7 +51,7 @@ Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/mai 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:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) *** @@ -61,7 +61,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:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) #### Returns @@ -75,7 +75,7 @@ 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:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) *** @@ -85,7 +85,7 @@ 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:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) *** @@ -95,7 +95,7 @@ Defined in: [useLiveInfiniteQuery.ts:97](https://github.com/TanStack/db/blob/mai isEnabled: true; ``` -Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L98) +Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) *** @@ -105,7 +105,7 @@ Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/mai isError: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L96) +Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L85) *** @@ -115,7 +115,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:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) *** @@ -125,7 +125,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:84](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L84) *** @@ -135,7 +135,7 @@ 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:79](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L79) *** @@ -145,7 +145,7 @@ Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/mai isPersistedReady: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) +Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L82) *** @@ -155,7 +155,7 @@ 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:80](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L80) *** @@ -165,7 +165,7 @@ Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/mai pageParams: number[]; ``` -Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L100) +Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) *** @@ -175,7 +175,7 @@ Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/ma pages: TResult[][]; ``` -Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L99) +Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) *** @@ -185,7 +185,7 @@ Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/mai persistedError: unknown | undefined; ``` -Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L94) +Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L83) *** @@ -195,7 +195,7 @@ Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/mai persistedStatus: LiveQueryPersistedStatus; ``` -Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) +Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L81) *** @@ -205,7 +205,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:76](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L76) *** @@ -215,4 +215,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:78](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L78) diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md index f42f993e61..c1d5e8c30a 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveQueryConfig type UseLiveQueryConfig = UseLiveQueryConfigOptions & Pick, "query">; ``` -Defined in: [useLiveQuery.ts:129](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L129) +Defined in: [useLiveQuery.ts:131](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L131) ## Type Parameters diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md index 4219dcacaa..367e197d52 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md @@ -7,4 +7,4 @@ title: UseLiveQueryStatus type UseLiveQueryStatus = CollectionStatus | "disabled"; ``` -Defined in: [useLiveQuery.ts:108](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L108) +Defined in: [useLiveQuery.ts:110](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L110) diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index 5eeea14c81..a3d27646e4 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -140,9 +140,12 @@ When the derived identity changes: 3. The component re-renders with the new data 4. The utility shows loading state again -Use `queryKey` for opaque functional variants such as `.fn.where`, because DB -cannot inspect their closed-over values. Pass reactive key values through a -getter: +Without a `DbClient`, Svelte compiles again whenever a reactive read changes, +so an opaque functional variant such as `.fn.where` works without a key. A +`DbClient` matches preloaded and hydrated queries by identity hash, so a query +it backs needs one: an opaque query without `queryKey` throws +`UnhashableQueryIRError`, and an empty `queryKey` counts as none. Pass reactive +key values through a getter: ```svelte ``` +While a `DbClient`-backed query is mounted, each source Collection ID it +reads must keep naming one Collection object, because its identity names +sources by ID. A different object under an ID the hook already read throws +`SourceCollectionIdReuseError`, and every result property then throws instead +of returning the previous query's state. + The legacy dependency array remains supported. Prefer derived identity for structured queries and `queryKey` for opaque ones. diff --git a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md index fa9dc3b9b9..402bcdab78 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:74](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L74) 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:85](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L85) 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..8fbbb5b92d 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:212](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L212) Create a live query using a query function @@ -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:218](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L218) Create a live query using a query function @@ -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:266](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L266) Create a live query using configuration object @@ -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:315](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L315) Subscribe to an existing query collection (can be reactive) @@ -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:326](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L326) Create a live query using a query function diff --git a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md index aedd412d40..900ea4cd44 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,7 +38,7 @@ 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() @@ -50,7 +50,7 @@ Reactive array of query results in order, or single item when using findOne() 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 @@ -62,7 +62,7 @@ True when query has been cleaned up isError: 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 @@ -74,7 +74,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:70](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L70) True when query hasn't started yet @@ -86,7 +86,7 @@ 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 @@ -98,7 +98,7 @@ True while initial query data is loading isPersistedReady: 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:68](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L68) *** @@ -108,7 +108,7 @@ 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 @@ -120,7 +120,7 @@ True when query has received first data and is ready persistedError: unknown; ``` -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) +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) *** @@ -130,7 +130,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:67](https://github.co persistedStatus: LiveQueryPersistedStatus; ``` -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) +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) *** @@ -140,7 +140,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 +152,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..7d70025831 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:88](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L88) ## 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:96](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L96) *** @@ -41,7 +41,7 @@ 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:95](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L95) *** @@ -51,7 +51,7 @@ 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:105](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L105) *** @@ -61,7 +61,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:103](https://github.c isError: 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:104](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L104) *** @@ -71,7 +71,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,7 +81,7 @@ 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:98](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L98) *** @@ -91,7 +91,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:96](https://github.co isPersistedReady: 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:101](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L101) *** @@ -101,7 +101,7 @@ 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:99](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L99) *** @@ -111,7 +111,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:97](https://github.co persistedError: unknown; ``` -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) +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) *** @@ -121,7 +121,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:100](https://github.c 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) +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) *** @@ -131,7 +131,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:94](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L94) *** @@ -141,4 +141,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:97](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L97) diff --git a/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md b/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md index 0d6fb132cc..f5c199d08f 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:80](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L80) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md b/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md index e77b794366..8e7bb277cd 100644 --- a/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md +++ b/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: LiveInfiniteQueryConfig type LiveInfiniteQueryConfig<_TRow> = InfiniteQueryOptions; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:46](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L46) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:31](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L31) ## Type Parameters diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md index f2bb22bed8..5e82c053e0 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig = LiveInfiniteQueryConfig[number]>; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:48](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L48) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:33](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L33) ## Type Parameters diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md index 49f4f851f7..265c6e4592 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturn type UseLiveInfiniteQueryReturn = Omit, "data"> & object; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:51](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L51) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:36](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L36) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index d95e565a1e..026cc6a1cc 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:49](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L49) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md index 4c1468d9a8..e8d7b55b3e 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:110](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L110) ## Type Declaration diff --git a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md index cb2476a85a..67ecfd3ea6 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:89](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L89) 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:100](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L100) 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/interfaces/UseLiveInfiniteQueryReturn.md b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md index ee516e947c..1938079042 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md @@ -3,7 +3,7 @@ id: UseLiveInfiniteQueryReturn title: UseLiveInfiniteQueryReturn --- -Defined in: [useLiveInfiniteQuery.ts:48](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L48) +Defined in: [useLiveInfiniteQuery.ts:35](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L35) ## Type Parameters @@ -19,7 +19,7 @@ Defined in: [useLiveInfiniteQuery.ts:48](https://github.com/TanStack/db/blob/mai collection: ComputedRef[K] }, string | number, UtilsRecord, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[K] }>>; ``` -Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L53) +Defined in: [useLiveInfiniteQuery.ts:40](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L40) *** @@ -29,7 +29,7 @@ Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/mai data: ComputedRef>; ``` -Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L52) +Defined in: [useLiveInfiniteQuery.ts:39](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L39) *** @@ -39,7 +39,7 @@ Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/mai error: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) +Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L57) *** @@ -49,7 +49,7 @@ Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/mai 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:54](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L54) #### Returns @@ -63,7 +63,7 @@ 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:55](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L55) *** @@ -73,7 +73,7 @@ Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/mai isCleanedUp: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:64](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L64) +Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L51) *** @@ -83,7 +83,7 @@ Defined in: [useLiveInfiniteQuery.ts:64](https://github.com/TanStack/db/blob/mai isError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L63) +Defined in: [useLiveInfiniteQuery.ts:50](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L50) *** @@ -93,7 +93,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:56](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L56) *** @@ -103,7 +103,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:49](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L49) *** @@ -113,7 +113,7 @@ Defined in: [useLiveInfiniteQuery.ts:62](https://github.com/TanStack/db/blob/mai isLoading: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L57) +Defined in: [useLiveInfiniteQuery.ts:44](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L44) *** @@ -123,7 +123,7 @@ Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/mai isPersistedReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L60) +Defined in: [useLiveInfiniteQuery.ts:47](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L47) *** @@ -133,7 +133,7 @@ Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/mai isReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L58) +Defined in: [useLiveInfiniteQuery.ts:45](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L45) *** @@ -143,7 +143,7 @@ Defined in: [useLiveInfiniteQuery.ts:58](https://github.com/TanStack/db/blob/mai pageParams: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L66) +Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L53) *** @@ -153,7 +153,7 @@ Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/mai pages: ComputedRef[number][][]>; ``` -Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L65) +Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L52) *** @@ -163,7 +163,7 @@ Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/mai persistedError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L61) +Defined in: [useLiveInfiniteQuery.ts:48](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L48) *** @@ -173,7 +173,7 @@ Defined in: [useLiveInfiniteQuery.ts:61](https://github.com/TanStack/db/blob/mai persistedStatus: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L59) +Defined in: [useLiveInfiniteQuery.ts:46](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L46) *** @@ -183,7 +183,7 @@ Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/mai state: ComputedRef[K] }>>; ``` -Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L51) +Defined in: [useLiveInfiniteQuery.ts:38](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L38) *** @@ -193,4 +193,4 @@ Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/mai status: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L56) +Defined in: [useLiveInfiniteQuery.ts:43](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L43) diff --git a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md index 5c8d419dcd..e65efbf151 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:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L60) ## 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:67](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L67) *** @@ -37,7 +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) +Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L66) *** @@ -47,7 +47,7 @@ Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/mai 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:82](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L82) *** @@ -57,7 +57,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:79](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L79) #### Returns @@ -71,7 +71,7 @@ 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:80](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L80) *** @@ -81,7 +81,7 @@ 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:76](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L76) *** @@ -91,7 +91,7 @@ Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/mai isError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L88) +Defined in: [useLiveInfiniteQuery.ts:75](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L75) *** @@ -101,7 +101,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:81](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L81) *** @@ -111,7 +111,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:74](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L74) *** @@ -121,7 +121,7 @@ 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:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L69) *** @@ -131,7 +131,7 @@ Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/mai isPersistedReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L85) +Defined in: [useLiveInfiniteQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L72) *** @@ -141,7 +141,7 @@ 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:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) *** @@ -151,7 +151,7 @@ Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/mai pageParams: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L91) +Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L78) *** @@ -161,7 +161,7 @@ Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/mai pages: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L90) +Defined in: [useLiveInfiniteQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L77) *** @@ -171,7 +171,7 @@ Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/mai persistedError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L86) +Defined in: [useLiveInfiniteQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L73) *** @@ -181,7 +181,7 @@ Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/mai persistedStatus: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L84) +Defined in: [useLiveInfiniteQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L71) *** @@ -191,7 +191,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:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L65) *** @@ -201,4 +201,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:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L68) diff --git a/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md b/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md index 970ee0937b..2993d0e02d 100644 --- a/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md +++ b/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: LiveInfiniteQueryConfig type LiveInfiniteQueryConfig<_TRow> = InfiniteQueryOptions; ``` -Defined in: [useLiveInfiniteQuery.ts:42](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L42) +Defined in: [useLiveInfiniteQuery.ts:29](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L29) ## Type Parameters diff --git a/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md index 857c59921a..c30368bf12 100644 --- a/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig = LiveInfiniteQueryConfig[number]>; ``` -Defined in: [useLiveInfiniteQuery.ts:44](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L44) +Defined in: [useLiveInfiniteQuery.ts:31](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L31) ## Type Parameters diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 8b1ea145c8..cee2386e42 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -208,9 +208,9 @@ export class UserListComponent { > **Note:** React hooks derive query identity from structured query IR by > default. Dependency arrays are still accepted for backwards compatibility, -> but warn in development and will be removed in 1.0. Unhashable queries also -> warn and keep legacy mount-stable identity until 1.0; add `queryKey` to make -> captured opaque values reactive. See the [React Adapter +> but warn in development and will be removed in 1.0. In React and Preact, an +> unhashable query without `queryKey` throws `UnhashableQueryIRError`. See the +> [React Adapter > documentation](../framework/react/overview#query-identity) for details. For server rendering and hydration, live query preloading transports the ordered @@ -240,12 +240,23 @@ function UserSearch({ search }: { search: string }) { You can also provide a `queryKey` as a performance escape hatch for a very hot render path, but normal structured queries should omit it. -React development builds detect both cases. Before 1.0, opaque, unhashable IR -warns and keeps legacy mount-stable identity; repeated expensive identity -derivation also warns once. Both warnings point to the same `queryKey` escape -hatch. - -For more details on framework integration, see the [React](../framework/react/overview), [Vue](../framework/vue/overview), and [Angular](../framework/angular/overview) adapter documentation. +Query identity matters where a hook compares it to decide whether to compile +again: React and Preact compare it on every render, and a `DbClient` matches +preloaded and hydrated queries by it. Those hooks throw +`UnhashableQueryIRError` when they compile an opaque, unhashable query without +`queryKey`; an empty `queryKey` counts as none. React development builds also +warn once when deriving identity becomes expensive, pointing to the same +`queryKey` escape hatch. Vue, Solid, Angular, and Svelte without a `DbClient` +compile again whenever their reactive reads change, so they need no identity +and accept any query. + +The derived identity names each source by its Collection ID. While a hook that +compares identity is mounted, each Collection ID its queries read must keep +naming one source Collection object; a different object under an ID the hook +already read throws `SourceCollectionIdReuseError` instead of returning rows +from the old source. + +For more details on framework integration, see the [React](../framework/react/overview), [Preact](../framework/preact/overview), [Vue](../framework/vue/overview), [Solid](../framework/solid/overview), [Svelte](../framework/svelte/overview), and [Angular](../framework/angular/overview) adapter documentation. ### Using with React Suspense diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index 8fa0c17011..4163f457d1 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -579,10 +579,11 @@ Common reasons to add `queryKey`: the structured query - a render path where derived identity becomes measurably expensive -Before 1.0, DB warns when structured IR cannot be hashed and preserves the -legacy mount-stable identity. The query still works, but captured values inside -opaque logic are not reactive unless they are represented in `queryKey`. In 1.0, -an unhashable query without `queryKey` will throw. +When structured IR cannot be hashed and the query has no `queryKey`, a hook +that compares identity throws `UnhashableQueryIRError`: React and Preact hooks, +and any hook backed by a `DbClient`, which matches queries by identity hash. An +empty `queryKey` counts as none. Represent the values that opaque logic +captures in `queryKey`, so the query recompiles when they change. DB also warns once in development if deriving identity becomes expensive enough that an explicit `queryKey` would be better. @@ -771,8 +772,8 @@ Still supported: Warnings: - React dependency arrays warn in development and will be removed in 1.0. -- Opaque query IR without `queryKey` warns in development and keeps legacy - mount-stable identity until 1.0. In 1.0 it will throw. +- Opaque query IR without `queryKey` throws `UnhashableQueryIRError` in a + hook that compares identity (React, Preact, or any `DbClient`-backed hook). - Expensive derived identity warns in development and suggests `queryKey`. Required for SSR: diff --git a/docs/installation.md b/docs/installation.md index dd3e7d001f..070a005f8e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,6 +13,14 @@ npm install @tanstack/react-db TanStack DB is compatible with React v18+ +## Preact + +```sh +npm install @tanstack/preact-db +``` + +TanStack DB is compatible with Preact v10+ + ## Solid ```sh diff --git a/docs/reference/classes/SourceCollectionIdReuseError.md b/docs/reference/classes/SourceCollectionIdReuseError.md new file mode 100644 index 0000000000..db7e5101c8 --- /dev/null +++ b/docs/reference/classes/SourceCollectionIdReuseError.md @@ -0,0 +1,248 @@ +--- +id: SourceCollectionIdReuseError +title: SourceCollectionIdReuseError +--- + +Defined in: [packages/db/src/errors.ts:1184](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1184) + +Error thrown when a mounted framework binding that compares query identity +reads a different source Collection under a Collection ID it has already +read, in an earlier query or the same one. The identity names each source by +its ID, so the binding could otherwise keep a live query that reads the old +source. + +## Extends + +- [`TanStackDBError`](TanStackDBError.md) + +## Constructors + +### Constructor + +```ts +new SourceCollectionIdReuseError(bindingName, collectionId): SourceCollectionIdReuseError; +``` + +Defined in: [packages/db/src/errors.ts:1185](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1185) + +#### Parameters + +##### bindingName + +`string` + +##### collectionId + +`string` + +#### Returns + +`SourceCollectionIdReuseError` + +#### Overrides + +[`TanStackDBError`](TanStackDBError.md).[`constructor`](TanStackDBError.md#constructor) + +## Properties + +### bindingName + +```ts +readonly bindingName: string; +``` + +Defined in: [packages/db/src/errors.ts:1186](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1186) + +*** + +### 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 + +[`TanStackDBError`](TanStackDBError.md).[`cause`](TanStackDBError.md#cause) + +*** + +### collectionId + +```ts +readonly collectionId: string; +``` + +Defined in: [packages/db/src/errors.ts:1187](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1187) + +*** + +### message + +```ts +message: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`message`](TanStackDBError.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 + +[`TanStackDBError`](TanStackDBError.md).[`name`](TanStackDBError.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 + +[`TanStackDBError`](TanStackDBError.md).[`stack`](TanStackDBError.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 + +[`TanStackDBError`](TanStackDBError.md).[`stackTraceLimit`](TanStackDBError.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 + +[`TanStackDBError`](TanStackDBError.md).[`captureStackTrace`](TanStackDBError.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 + +[`TanStackDBError`](TanStackDBError.md).[`prepareStackTrace`](TanStackDBError.md#preparestacktrace) diff --git a/docs/reference/classes/TanStackDBError.md b/docs/reference/classes/TanStackDBError.md index e72deb3f89..48f83f56a7 100644 --- a/docs/reference/classes/TanStackDBError.md +++ b/docs/reference/classes/TanStackDBError.md @@ -27,6 +27,7 @@ Defined in: [packages/db/src/errors.ts:4](https://github.com/TanStack/db/blob/ma - [`SyncCleanupError`](SyncCleanupError.md) - [`QueryOptimizerError`](QueryOptimizerError.md) - [`SetWindowReentrancyError`](SetWindowReentrancyError.md) +- [`SourceCollectionIdReuseError`](SourceCollectionIdReuseError.md) ## Constructors diff --git a/docs/reference/functions/assertLiveQueryWindowManyResult.md b/docs/reference/functions/assertLiveQueryWindowManyResult.md deleted file mode 100644 index 5075f4953d..0000000000 --- a/docs/reference/functions/assertLiveQueryWindowManyResult.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: assertLiveQueryWindowManyResult -title: assertLiveQueryWindowManyResult ---- - -```ts -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) - -**`Internal`** - -Shared validation for infinite-query adapters. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -## Returns - -`void` diff --git a/docs/reference/functions/bindLiveQueryWindow.md b/docs/reference/functions/bindLiveQueryWindow.md new file mode 100644 index 0000000000..5a96db2996 --- /dev/null +++ b/docs/reference/functions/bindLiveQueryWindow.md @@ -0,0 +1,39 @@ +--- +id: bindLiveQueryWindow +title: bindLiveQueryWindow +--- + +```ts +function bindLiveQueryWindow( + previous, + update, + uncommitted?): LiveQueryWindowBinding; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:111 + +Bind an infinite-query update. Returns `previous` when it still serves the +update; otherwise returns a new binding whose controller the adapter +subscribes and later disposes. + +`uncommitted` is a binding built by an earlier update that never took +effect, such as a React render that did not commit. When it answers this +update, its window collection is reused instead of compiling another. + +## Parameters + +### previous + +[`LiveQueryWindowBinding`](../interfaces/LiveQueryWindowBinding.md) | `null` + +### update + +[`LiveQueryWindowUpdate`](../interfaces/LiveQueryWindowUpdate.md) + +### uncommitted? + +[`LiveQueryWindowBinding`](../interfaces/LiveQueryWindowBinding.md) | `null` + +## Returns + +[`LiveQueryWindowBinding`](../interfaces/LiveQueryWindowBinding.md) diff --git a/docs/reference/functions/claimLiveQuerySourceIds.md b/docs/reference/functions/claimLiveQuerySourceIds.md new file mode 100644 index 0000000000..e8234d6721 --- /dev/null +++ b/docs/reference/functions/claimLiveQuerySourceIds.md @@ -0,0 +1,38 @@ +--- +id: claimLiveQuerySourceIds +title: claimLiveQuerySourceIds +--- + +```ts +function claimLiveQuerySourceIds( + claims, + sources, + client, + bindingName): void; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:100](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L100) + +Record a query's sources, or throw before recording any. + +## Parameters + +### claims + +[`LiveQuerySourceIdClaims`](../type-aliases/LiveQuerySourceIdClaims.md) + +### sources + +readonly `object`[] + +### client + +[`DbClient`](../classes/DbClient.md) | `undefined` + +### bindingName + +`string` + +## Returns + +`void` diff --git a/docs/reference/functions/compareLiveQueryWindowDependencies.md b/docs/reference/functions/compareLiveQueryWindowDependencies.md deleted file mode 100644 index a86091daaf..0000000000 --- a/docs/reference/functions/compareLiveQueryWindowDependencies.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: compareLiveQueryWindowDependencies -title: compareLiveQueryWindowDependencies ---- - -```ts -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) - -**`Internal`** - -Compare adapter dependencies by identity and structure. - -## Parameters - -### previous - -readonly `unknown`[] | `null` | `undefined` - -### current - -readonly `unknown`[] - -## Returns - -`object` - -### changed - -```ts -changed: boolean; -``` - -### structurallyEqual - -```ts -structurallyEqual: boolean; -``` diff --git a/docs/reference/functions/createLiveQueryObserver.md b/docs/reference/functions/createLiveQueryObserver.md index 5dd16de942..d7d96b0dc2 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:1131](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1131) **`Internal`** diff --git a/docs/reference/functions/createLiveQuerySourceIdClaims.md b/docs/reference/functions/createLiveQuerySourceIdClaims.md new file mode 100644 index 0000000000..1c5f4a6dce --- /dev/null +++ b/docs/reference/functions/createLiveQuerySourceIdClaims.md @@ -0,0 +1,14 @@ +--- +id: createLiveQuerySourceIdClaims +title: createLiveQuerySourceIdClaims +--- + +```ts +function createLiveQuerySourceIdClaims(): LiveQuerySourceIdClaims; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L95) + +## Returns + +[`LiveQuerySourceIdClaims`](../type-aliases/LiveQuerySourceIdClaims.md) diff --git a/docs/reference/functions/getLiveQueryHash.md b/docs/reference/functions/getLiveQueryHash.md index 0584039ee9..fc7b2e6d7b 100644 --- a/docs/reference/functions/getLiveQueryHash.md +++ b/docs/reference/functions/getLiveQueryHash.md @@ -7,7 +7,7 @@ title: getLiveQueryHash function getLiveQueryHash(preparedValue, queryKey?): string; ``` -Defined in: [packages/db/src/live-query-options.ts:193](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L193) +Defined in: [packages/db/src/live-query-options.ts:228](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L228) ## Parameters diff --git a/docs/reference/functions/getLiveQueryKey.md b/docs/reference/functions/getLiveQueryKey.md new file mode 100644 index 0000000000..5bc0095576 --- /dev/null +++ b/docs/reference/functions/getLiveQueryKey.md @@ -0,0 +1,24 @@ +--- +id: getLiveQueryKey +title: getLiveQueryKey +--- + +```ts +function getLiveQueryKey(input): LiveQueryKey | undefined; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:52](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L52) + +The `queryKey` of a config-object query input, if it supplies one. An empty +key is absent, as it is for `getLiveQueryHash`, so a hook and a `DbClient` +preload agree on the query's identity. + +## Parameters + +### input + +`unknown` + +## Returns + +[`LiveQueryKey`](../type-aliases/LiveQueryKey.md) \| `undefined` diff --git a/docs/reference/functions/getLiveQueryStatusFlags.md b/docs/reference/functions/getLiveQueryStatusFlags.md index 2d1a9bacde..0bf0a0f167 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-primitives.ts:64 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 deleted file mode 100644 index 74243914be..0000000000 --- a/docs/reference/functions/getLiveQueryWindowCollectionWarning.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: getLiveQueryWindowCollectionWarning -title: getLiveQueryWindowCollectionWarning ---- - -```ts -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) - -**`Internal`** - -Validate a pre-created infinite-query collection and describe any window -adjustment the adapter should warn about. - - Shared validation for infinite-query adapters. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -### expectedLimit - -`number` - -## Returns - -`string` \| `undefined` diff --git a/docs/reference/functions/getLiveQueryWindowInputKind.md b/docs/reference/functions/getLiveQueryWindowInputKind.md deleted file mode 100644 index ee6349e0bb..0000000000 --- a/docs/reference/functions/getLiveQueryWindowInputKind.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: getLiveQueryWindowInputKind -title: getLiveQueryWindowInputKind ---- - -```ts -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) - -**`Internal`** - -Classify an infinite-query input without invoking its query callback. -Frameworks use this during lifecycle comparison so unchanged React renders -do not execute the callback again. - - This contract is unstable while RFC #1623 is being implemented. - -## Parameters - -### input - -`unknown` - -## Returns - -[`LiveQueryWindowInputKind`](../type-aliases/LiveQueryWindowInputKind.md) diff --git a/docs/reference/functions/getPersistedReadinessSource.md b/docs/reference/functions/getPersistedReadinessSource.md index 2cf3ededbe..2901fd91a4 100644 --- a/docs/reference/functions/getPersistedReadinessSource.md +++ b/docs/reference/functions/getPersistedReadinessSource.md @@ -9,7 +9,7 @@ function getPersistedReadinessSource(config): | undefined; ``` -Defined in: [packages/db/src/persisted-readiness.ts:20](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L20) +Defined in: [packages/db/src/persisted-readiness.ts:160](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L160) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQueryIdentity.md b/docs/reference/functions/getPreparedLiveQueryIdentity.md index ed0c5a8b18..5877205e0b 100644 --- a/docs/reference/functions/getPreparedLiveQueryIdentity.md +++ b/docs/reference/functions/getPreparedLiveQueryIdentity.md @@ -7,7 +7,7 @@ title: getPreparedLiveQueryIdentity function getPreparedLiveQueryIdentity(value): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:165](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L165) +Defined in: [packages/db/src/live-query-options.ts:200](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L200) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQuerySources.md b/docs/reference/functions/getPreparedLiveQuerySources.md index 9752ca50cd..4c142c6129 100644 --- a/docs/reference/functions/getPreparedLiveQuerySources.md +++ b/docs/reference/functions/getPreparedLiveQuerySources.md @@ -7,7 +7,7 @@ title: getPreparedLiveQuerySources function getPreparedLiveQuerySources(value, onUnboundDescriptor?): Collection, string | number, UtilsRecord, StandardSchemaV1, Record>[]; ``` -Defined in: [packages/db/src/live-query-options.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L142) +Defined in: [packages/db/src/live-query-options.ts:177](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L177) Concrete source objects in query-position order for a prepared value. diff --git a/docs/reference/functions/getPublicCollection.md b/docs/reference/functions/getPublicCollection.md index 5dfa72998c..9ef5dce7c2 100644 --- a/docs/reference/functions/getPublicCollection.md +++ b/docs/reference/functions/getPublicCollection.md @@ -7,7 +7,7 @@ title: getPublicCollection function getPublicCollection(collection): T; ``` -Defined in: [packages/db/src/live-query-adapter.ts:38](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L38) +Defined in: packages/db/src/live-query-primitives.ts:31 The Collection an adapter hands users as `collection`. A pooled live query stands in for its live-query Collection and builds it only when touched. diff --git a/docs/reference/functions/getSourceObjectToken.md b/docs/reference/functions/getSourceObjectToken.md new file mode 100644 index 0000000000..76a91cd558 --- /dev/null +++ b/docs/reference/functions/getSourceObjectToken.md @@ -0,0 +1,22 @@ +--- +id: getSourceObjectToken +title: getSourceObjectToken +--- + +```ts +function getSourceObjectToken(source): number; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:77](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L77) + +A process-unique number for a source object, without retaining it. + +## Parameters + +### source + +`object` + +## Returns + +`number` diff --git a/docs/reference/functions/hasLiveQueryWindowLeases.md b/docs/reference/functions/hasLiveQueryWindowLeases.md deleted file mode 100644 index 8f686fb8ce..0000000000 --- a/docs/reference/functions/hasLiveQueryWindowLeases.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: hasLiveQueryWindowLeases -title: hasLiveQueryWindowLeases ---- - -```ts -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) - -**`Internal`** - -Whether an infinite-query controller currently owns this window. - -## Parameters - -### target - -`object` - -## Returns - -`boolean` diff --git a/docs/reference/functions/identifyLiveQuery.md b/docs/reference/functions/identifyLiveQuery.md new file mode 100644 index 0000000000..883dda2975 --- /dev/null +++ b/docs/reference/functions/identifyLiveQuery.md @@ -0,0 +1,43 @@ +--- +id: identifyLiveQuery +title: identifyLiveQuery +--- + +```ts +function identifyLiveQuery( + preparedValue, + scope, + identify): TIdentity; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:141](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L141) + +Identify a prepared query for a binding that compares query identity. The +identity throws `UnhashableQueryIRError` for IR without a stable identity, +then the query's sources claim their Collection IDs. Either rejection +releases the sync starts the preparation deferred, so a later reader, such +as another hook over the same Collection, can still start those sources. + +## Type Parameters + +### TIdentity + +`TIdentity` = `unknown` + +## Parameters + +### preparedValue + +`unknown` + +### scope + +[`LiveQueryIdentityScope`](../interfaces/LiveQueryIdentityScope.md) + +### identify + +(`preparedValue`) => `TIdentity` + +## Returns + +`TIdentity` diff --git a/docs/reference/functions/isCollection.md b/docs/reference/functions/isCollection.md index 212c4ba5b3..6238dfa28c 100644 --- a/docs/reference/functions/isCollection.md +++ b/docs/reference/functions/isCollection.md @@ -7,7 +7,7 @@ title: isCollection function isCollection(value): value is Collection, any>; ``` -Defined in: [packages/db/src/live-query-adapter.ts:22](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L22) +Defined in: packages/db/src/live-query-primitives.ts:15 Structural check for a live-query/`Collection` instance. diff --git a/docs/reference/functions/isLiveQueryWindowCollection.md b/docs/reference/functions/isLiveQueryWindowCollection.md deleted file mode 100644 index 94259865af..0000000000 --- a/docs/reference/functions/isLiveQueryWindowCollection.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: isLiveQueryWindowCollection -title: isLiveQueryWindowCollection ---- - -```ts -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) - -**`Internal`** - -Whether a collection exposes an active ordered window. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -## Returns - -`collection is LiveQueryWindowCollection` diff --git a/docs/reference/functions/isSingleResultCollection.md b/docs/reference/functions/isSingleResultCollection.md index 88e00a2e20..091ad63055 100644 --- a/docs/reference/functions/isSingleResultCollection.md +++ b/docs/reference/functions/isSingleResultCollection.md @@ -7,7 +7,7 @@ title: isSingleResultCollection function isSingleResultCollection(collection): boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L48) +Defined in: packages/db/src/live-query-primitives.ts:41 Whether a collection yields a single result (`findOne`) rather than an array. diff --git a/docs/reference/functions/liveQueryWindowMatches.md b/docs/reference/functions/liveQueryWindowMatches.md deleted file mode 100644 index d8cb412b41..0000000000 --- a/docs/reference/functions/liveQueryWindowMatches.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: liveQueryWindowMatches -title: liveQueryWindowMatches ---- - -```ts -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) - -**`Internal`** - -Whether a window collection may start from its current window. An adapter -may then start the collection, or reuse one it started, during render, as -useLiveQuery does. That holds for exactly the requested rows from offset 0, -whose first published rows are already correct. It also holds for an -unbounded window, which the controller cannot narrow before its source -request anyway. Any other window waits for the controller to adjust it when -the subscription commits: a shifted or narrower window would publish the -wrong rows first, and a wider finite one would request rows from an -on-demand source that the hook does not need. - - This contract is unstable while RFC #1623 is being implemented. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -### requiredLimit - -`number` - -## Returns - -`boolean` diff --git a/docs/reference/functions/normalizeLiveQueryWindowPageSize.md b/docs/reference/functions/normalizeLiveQueryWindowPageSize.md deleted file mode 100644 index 867d7dc165..0000000000 --- a/docs/reference/functions/normalizeLiveQueryWindowPageSize.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: normalizeLiveQueryWindowPageSize -title: normalizeLiveQueryWindowPageSize ---- - -```ts -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) - -**`Internal`** - -This contract is unstable while RFC #1623 is being implemented. - -## Parameters - -### pageSize - -`number` | `undefined` - -## Returns - -`number` diff --git a/docs/reference/functions/prepareLiveQueryValue.md b/docs/reference/functions/prepareLiveQueryValue.md index 8a7c287e3c..be2dcb70be 100644 --- a/docs/reference/functions/prepareLiveQueryValue.md +++ b/docs/reference/functions/prepareLiveQueryValue.md @@ -10,7 +10,12 @@ function prepareLiveQueryValue( deferredCollections?): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:89](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L89) +Defined in: [packages/db/src/live-query-options.ts:112](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L112) + +Bind a query input's descriptor sources to `dbClient` and invoke its query +callback. Sources materialized for a render defer their sync start into +`deferredCollections`; a preparation that throws releases them, so a later +reader can still start those sources. ## Parameters diff --git a/docs/reference/functions/readLiveQueryWindowPageShape.md b/docs/reference/functions/readLiveQueryWindowPageShape.md new file mode 100644 index 0000000000..2981d7c4bf --- /dev/null +++ b/docs/reference/functions/readLiveQueryWindowPageShape.md @@ -0,0 +1,29 @@ +--- +id: readLiveQueryWindowPageShape +title: readLiveQueryWindowPageShape +--- + +```ts +function readLiveQueryWindowPageShape(config): LiveQueryWindowPageShape; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:43 + +Read an infinite-query config's page shape. `getNextPageParam` is rejected: +the hook widens an ordered live query and has no server cursor to advance. + +## Parameters + +### config + +#### initialPageParam? + +`number` + +#### pageSize? + +`number` + +## Returns + +[`LiveQueryWindowPageShape`](../interfaces/LiveQueryWindowPageShape.md) diff --git a/docs/reference/functions/resolveLiveQueryValue.md b/docs/reference/functions/resolveLiveQueryValue.md index 47c59c06b4..86bceeaf72 100644 --- a/docs/reference/functions/resolveLiveQueryValue.md +++ b/docs/reference/functions/resolveLiveQueryValue.md @@ -9,7 +9,12 @@ function resolveLiveQueryValue(value, __namedParameters): | null; ``` -Defined in: [packages/db/src/live-query-options.ts:229](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L229) +Defined in: [packages/db/src/live-query-options.ts:264](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L264) + +Resolve an adapter's query value to what its observer watches: `null` for a +disabled query, an existing Collection with sync started, or a live query. +A query builder whose shape a shared partition can serve gets a pooled view +instead of its own live-query Collection. ## Parameters diff --git a/docs/reference/functions/resolveLiveQueryWindowInput.md b/docs/reference/functions/resolveLiveQueryWindowInput.md deleted file mode 100644 index 8519dcf4ec..0000000000 --- a/docs/reference/functions/resolveLiveQueryWindowInput.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: resolveLiveQueryWindowInput -title: resolveLiveQueryWindowInput ---- - -```ts -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) - -**`Internal`** - -Resolve a supported infinite-query input and invoke a query callback once. -A function may resolve to a collection for framework getter compatibility. -Nullable/disabled and config-object inputs are intentionally not supported. - - This contract is unstable while RFC #1623 is being implemented. - -## Type Parameters - -### TContext - -`TContext` *extends* [`Context`](../interfaces/Context.md) - -## Parameters - -### input - -`unknown` - -## Returns - -[`ResolvedLiveQueryWindowInput`](../type-aliases/ResolvedLiveQueryWindowInput.md)\<`TContext`\> diff --git a/docs/reference/functions/resumeDeferredLiveQueryCollections.md b/docs/reference/functions/resumeDeferredLiveQueryCollections.md index f42da0e2ef..3b1708e9f0 100644 --- a/docs/reference/functions/resumeDeferredLiveQueryCollections.md +++ b/docs/reference/functions/resumeDeferredLiveQueryCollections.md @@ -7,7 +7,7 @@ title: resumeDeferredLiveQueryCollections function resumeDeferredLiveQueryCollections(collections): void; ``` -Defined in: [packages/db/src/live-query-options.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L36) +Defined in: [packages/db/src/live-query-options.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L53) Release every deferred source, then report the first startup failure. diff --git a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md b/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md deleted file mode 100644 index 905a176595..0000000000 --- a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: shouldPreserveLiveQueryWindowPageCount -title: shouldPreserveLiveQueryWindowPageCount ---- - -```ts -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) - -**`Internal`** - -Shared page-depth preservation policy for framework adapters. - -## Parameters - -### options - -#### dependenciesChanged - -`boolean` - -#### dependenciesStructurallyEqual - -`boolean` - -#### hasPreviousController - -`boolean` - -#### inputKind - -`"collection"` \| `"query"` - -#### pageShapeChanged - -`boolean` - -#### previousInputKind - -`"collection"` \| `"query"` \| `undefined` - -#### sameCollection - -`boolean` - -## Returns - -`boolean` diff --git a/docs/reference/functions/waitForPersistedInitialRender.md b/docs/reference/functions/waitForPersistedInitialRender.md new file mode 100644 index 0000000000..009df2518c --- /dev/null +++ b/docs/reference/functions/waitForPersistedInitialRender.md @@ -0,0 +1,40 @@ +--- +id: waitForPersistedInitialRender +title: waitForPersistedInitialRender +--- + +```ts +function waitForPersistedInitialRender(__namedParameters): object; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L56) + +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. + +## Parameters + +### \_\_namedParameters + +[`PersistedInitialRenderInput`](../interfaces/PersistedInitialRenderInput.md) + +## Returns + +`object` + +### cancel() + +```ts +cancel: () => void; +``` + +#### Returns + +`void` + +### promise + +```ts +promise: Promise; +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 486890d811..1260894b5e 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -97,6 +97,7 @@ title: "@tanstack/db" - [SetWindowReentrancyError](classes/SetWindowReentrancyError.md) - [SetWindowRequiresOrderByError](classes/SetWindowRequiresOrderByError.md) - [SortedMap](classes/SortedMap.md) +- [SourceCollectionIdReuseError](classes/SourceCollectionIdReuseError.md) - [StorageError](classes/StorageError.md) - [StorageKeyRequiredError](classes/StorageKeyRequiredError.md) - [SubQueryMustHaveFromClauseError](classes/SubQueryMustHaveFromClauseError.md) @@ -157,11 +158,15 @@ title: "@tanstack/db" - [IndexSuggestion](interfaces/IndexSuggestion.md) - [InsertConfig](interfaces/InsertConfig.md) - [LiveQueryCollectionConfig](interfaces/LiveQueryCollectionConfig.md) +- [LiveQueryIdentityScope](interfaces/LiveQueryIdentityScope.md) - [LiveQueryObserver](interfaces/LiveQueryObserver.md) - [LiveQuerySnapshot](interfaces/LiveQuerySnapshot.md) - [LiveQueryStatusFlags](interfaces/LiveQueryStatusFlags.md) +- [LiveQueryWindowBinding](interfaces/LiveQueryWindowBinding.md) - [LiveQueryWindowController](interfaces/LiveQueryWindowController.md) +- [LiveQueryWindowPageShape](interfaces/LiveQueryWindowPageShape.md) - [LiveQueryWindowSnapshot](interfaces/LiveQueryWindowSnapshot.md) +- [LiveQueryWindowUpdate](interfaces/LiveQueryWindowUpdate.md) - [LocalOnlyCollectionConfig](interfaces/LocalOnlyCollectionConfig.md) - [LocalOnlyCollectionUtils](interfaces/LocalOnlyCollectionUtils.md) - [LocalStorageCollectionConfig](interfaces/LocalStorageCollectionConfig.md) @@ -172,6 +177,7 @@ title: "@tanstack/db" - [Parser](interfaces/Parser.md) - [ParseWhereOptions](interfaces/ParseWhereOptions.md) - [PendingMutation](interfaces/PendingMutation.md) +- [PersistedInitialRenderInput](interfaces/PersistedInitialRenderInput.md) - [PersistedReadinessSource](interfaces/PersistedReadinessSource.md) - [QueryOnceConfig](interfaces/QueryOnceConfig.md) - [QueueStrategy](interfaces/QueueStrategy.md) @@ -257,8 +263,8 @@ title: "@tanstack/db" - [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) +- [LiveQuerySourceIdClaims](type-aliases/LiveQuerySourceIdClaims.md) +- [LiveQueryWindowRecompile](type-aliases/LiveQueryWindowRecompile.md) - [LoadSubsetFn](type-aliases/LoadSubsetFn.md) - [LoadSubsetOptions](type-aliases/LoadSubsetOptions.md) - [LoadSubsetRequestResult](type-aliases/LoadSubsetRequestResult.md) @@ -283,7 +289,6 @@ title: "@tanstack/db" - [QueryResult](type-aliases/QueryResult.md) - [Ref](type-aliases/Ref.md) - [RefsForContext](type-aliases/RefsForContext.md) -- [ResolvedLiveQueryWindowInput](type-aliases/ResolvedLiveQueryWindowInput.md) - [ResolveTransactionChanges](type-aliases/ResolveTransactionChanges.md) - [ResultStream](type-aliases/ResultStream.md) - [ResultTypeFromSelect](type-aliases/ResultTypeFromSelect.md) @@ -336,15 +341,15 @@ title: "@tanstack/db" - [add](functions/add.md) - [and](functions/and.md) -- [assertLiveQueryWindowManyResult](functions/assertLiveQueryWindowManyResult.md) - [avg](functions/avg.md) +- [bindLiveQueryWindow](functions/bindLiveQueryWindow.md) - [canonicalizeQueryIR](functions/canonicalizeQueryIR.md) - [caseWhen](functions/caseWhen.md) +- [claimLiveQuerySourceIds](functions/claimLiveQuerySourceIds.md) - [clear](functions/clear.md) - [clearQueryPatterns](functions/clearQueryPatterns.md) - [coalesce](functions/coalesce.md) - [collectionOptions](functions/collectionOptions.md) -- [compareLiveQueryWindowDependencies](functions/compareLiveQueryWindowDependencies.md) - [compareTemporalValues](functions/compareTemporalValues.md) - [compileExpression](functions/compileExpression.md) - [compileQuery](functions/compileQuery.md) @@ -359,6 +364,7 @@ title: "@tanstack/db" - [createIndexedDB](functions/createIndexedDB.md) - [createLiveQueryCollection](functions/createLiveQueryCollection.md) - [createLiveQueryObserver](functions/createLiveQueryObserver.md) +- [createLiveQuerySourceIdClaims](functions/createLiveQuerySourceIdClaims.md) - [createLiveQueryWindowController](functions/createLiveQueryWindowController.md) - [createObjectStore](functions/createObjectStore.md) - [createOptimisticAction](functions/createOptimisticAction.md) @@ -383,9 +389,8 @@ title: "@tanstack/db" - [getByKey](functions/getByKey.md) - [getIndexDevModeConfig](functions/getIndexDevModeConfig.md) - [getLiveQueryHash](functions/getLiveQueryHash.md) +- [getLiveQueryKey](functions/getLiveQueryKey.md) - [getLiveQueryStatusFlags](functions/getLiveQueryStatusFlags.md) -- [getLiveQueryWindowCollectionWarning](functions/getLiveQueryWindowCollectionWarning.md) -- [getLiveQueryWindowInputKind](functions/getLiveQueryWindowInputKind.md) - [getLoadSubsetDemandKey](functions/getLoadSubsetDemandKey.md) - [getPersistedReadinessSource](functions/getPersistedReadinessSource.md) - [getPreparedLiveQueryIdentity](functions/getPreparedLiveQueryIdentity.md) @@ -393,27 +398,26 @@ title: "@tanstack/db" - [getPublicCollection](functions/getPublicCollection.md) - [getQueryIdentity](functions/getQueryIdentity.md) - [getQueryPatterns](functions/getQueryPatterns.md) +- [getSourceObjectToken](functions/getSourceObjectToken.md) - [getStableQueryBuilderHash](functions/getStableQueryBuilderHash.md) - [getStableQueryIRHash](functions/getStableQueryIRHash.md) - [getStableValueHash](functions/getStableValueHash.md) - [gt](functions/gt.md) - [gte](functions/gte.md) -- [hasLiveQueryWindowLeases](functions/hasLiveQueryWindowLeases.md) - [hasVirtualProps](functions/hasVirtualProps.md) +- [identifyLiveQuery](functions/identifyLiveQuery.md) - [ilike](functions/ilike.md) - [inArray](functions/inArray.md) - [indexedDBCollectionOptions](functions/indexedDBCollectionOptions.md) - [isCollection](functions/isCollection.md) - [isCollectionOptions](functions/isCollectionOptions.md) - [isDevModeEnabled](functions/isDevModeEnabled.md) -- [isLiveQueryWindowCollection](functions/isLiveQueryWindowCollection.md) - [isNull](functions/isNull.md) - [isSingleResultCollection](functions/isSingleResultCollection.md) - [isUndefined](functions/isUndefined.md) - [length](functions/length.md) - [like](functions/like.md) - [liveQueryCollectionOptions](functions/liveQueryCollectionOptions.md) -- [liveQueryWindowMatches](functions/liveQueryWindowMatches.md) - [localOnlyCollectionOptions](functions/localOnlyCollectionOptions.md) - [localStorageCollectionOptions](functions/localStorageCollectionOptions.md) - [lower](functions/lower.md) @@ -424,7 +428,6 @@ title: "@tanstack/db" - [max](functions/max.md) - [min](functions/min.md) - [multiply](functions/multiply.md) -- [normalizeLiveQueryWindowPageSize](functions/normalizeLiveQueryWindowPageSize.md) - [not](functions/not.md) - [openDatabase](functions/openDatabase.md) - [optimizeExpressionWithIndexes](functions/optimizeExpressionWithIndexes.md) @@ -436,12 +439,11 @@ title: "@tanstack/db" - [put](functions/put.md) - [queryOnce](functions/queryOnce.md) - [queueStrategy](functions/queueStrategy.md) +- [readLiveQueryWindowPageShape](functions/readLiveQueryWindowPageShape.md) - [resetWarnings](functions/resetWarnings.md) - [resolveLiveQueryValue](functions/resolveLiveQueryValue.md) -- [resolveLiveQueryWindowInput](functions/resolveLiveQueryWindowInput.md) - [resumeDeferredLiveQueryCollections](functions/resumeDeferredLiveQueryCollections.md) - [safeRandomUUID](functions/safeRandomUUID.md) -- [shouldPreserveLiveQueryWindowPageCount](functions/shouldPreserveLiveQueryWindowPageCount.md) - [subtract](functions/subtract.md) - [sum](functions/sum.md) - [throttleStrategy](functions/throttleStrategy.md) @@ -450,6 +452,7 @@ title: "@tanstack/db" - [trackQuery](functions/trackQuery.md) - [upper](functions/upper.md) - [validateSyncPersistenceCapability](functions/validateSyncPersistenceCapability.md) +- [waitForPersistedInitialRender](functions/waitForPersistedInitialRender.md) - [walkExpression](functions/walkExpression.md) - [warnOnce](functions/warnOnce.md) - [whenSyncAccepted](functions/whenSyncAccepted.md) diff --git a/docs/reference/interfaces/CreateLiveQueryObserverOptions.md b/docs/reference/interfaces/CreateLiveQueryObserverOptions.md index 4f5786ec86..f24417f203 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:1098](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1098) ## 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:1115](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1115) 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:1113](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1113) 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:1119](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1119) 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:1117](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1117) Stable live-query identity used for dehydration and hydration. diff --git a/docs/reference/interfaces/LiveQueryIdentityScope.md b/docs/reference/interfaces/LiveQueryIdentityScope.md new file mode 100644 index 0000000000..74e055fd2e --- /dev/null +++ b/docs/reference/interfaces/LiveQueryIdentityScope.md @@ -0,0 +1,74 @@ +--- +id: LiveQueryIdentityScope +title: LiveQueryIdentityScope +--- + +Defined in: [packages/db/src/live-query-adapter.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L123) + +Where a binding that compares query identity checks a prepared query. + +## Properties + +### bindingName + +```ts +bindingName: string; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:127](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L127) + +*** + +### claims? + +```ts +optional claims: LiveQuerySourceIdClaims; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:125](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L125) + +This binding's claims; omit to check identity without claiming. + +*** + +### client + +```ts +client: DbClient | undefined; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L126) + +*** + +### deferredCollections? + +```ts +optional deferredCollections: DeferredLiveQueryCollections; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:129](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L129) + +Sync starts the query's preparation deferred. + +*** + +### onUnboundDescriptor()? + +```ts +optional onUnboundDescriptor: (alias) => never; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L131) + +Report a descriptor source that no client bound. + +#### Parameters + +##### alias + +`string` + +#### Returns + +`never` diff --git a/docs/reference/interfaces/LiveQueryStatusFlags.md b/docs/reference/interfaces/LiveQueryStatusFlags.md index 66382ef6dc..51bbe63c6e 100644 --- a/docs/reference/interfaces/LiveQueryStatusFlags.md +++ b/docs/reference/interfaces/LiveQueryStatusFlags.md @@ -3,7 +3,7 @@ id: LiveQueryStatusFlags title: LiveQueryStatusFlags --- -Defined in: [packages/db/src/live-query-adapter.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L58) +Defined in: packages/db/src/live-query-primitives.ts:51 The derived boolean status flags every adapter exposes for a query. @@ -15,7 +15,7 @@ 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-primitives.ts:56 *** @@ -25,7 +25,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:63](https://github.com/TanSta isError: 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-primitives.ts:55 *** @@ -35,7 +35,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:62](https://github.com/TanSta isIdle: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:61](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L61) +Defined in: packages/db/src/live-query-primitives.ts:54 *** @@ -45,7 +45,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:61](https://github.com/TanSta isLoading: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:59](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L59) +Defined in: packages/db/src/live-query-primitives.ts:52 *** @@ -55,4 +55,4 @@ Defined in: [packages/db/src/live-query-adapter.ts:59](https://github.com/TanSta isReady: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:60](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L60) +Defined in: packages/db/src/live-query-primitives.ts:53 diff --git a/docs/reference/interfaces/LiveQueryWindowBinding.md b/docs/reference/interfaces/LiveQueryWindowBinding.md new file mode 100644 index 0000000000..5e26861db1 --- /dev/null +++ b/docs/reference/interfaces/LiveQueryWindowBinding.md @@ -0,0 +1,121 @@ +--- +id: LiveQueryWindowBinding +title: LiveQueryWindowBinding +--- + +Defined in: packages/db/src/live-query-window-binding.ts:86 + +Validated infinite-query page shape. + +## Extends + +- [`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md) + +## Properties + +### collection + +```ts +collection: Collection; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:91 + +*** + +### controller + +```ts +controller: LiveQueryWindowController; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:94 + +*** + +### dependencies + +```ts +dependencies: readonly unknown[] | undefined; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:89 + +*** + +### initialPageParam + +```ts +initialPageParam: number; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:36 + +First result-page label, not a server cursor or remote offset. + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`initialPageParam`](LiveQueryWindowPageShape.md#initialpageparam) + +*** + +### ownsCollection + +```ts +ownsCollection: boolean; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:93 + +Whether this binding created `collection` from a query. + +*** + +### pageSize + +```ts +pageSize: number; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:34 + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`pageSize`](LiveQueryWindowPageShape.md#pagesize) + +*** + +### scope + +```ts +scope: unknown; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:90 + +*** + +### supplied + +```ts +supplied: + | Collection, any> + | undefined; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:88 + +The supplied Collection; `undefined` for a query input. + +*** + +### warning + +```ts +warning: string | undefined; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:99 + +A developer hint about a newly bound supplied collection's window, for the +adapter to print once. Production builds carry none. diff --git a/docs/reference/interfaces/LiveQueryWindowPageShape.md b/docs/reference/interfaces/LiveQueryWindowPageShape.md new file mode 100644 index 0000000000..b94a4331d9 --- /dev/null +++ b/docs/reference/interfaces/LiveQueryWindowPageShape.md @@ -0,0 +1,35 @@ +--- +id: LiveQueryWindowPageShape +title: LiveQueryWindowPageShape +--- + +Defined in: packages/db/src/live-query-window-binding.ts:33 + +Validated infinite-query page shape. + +## Extended by + +- [`LiveQueryWindowUpdate`](LiveQueryWindowUpdate.md) +- [`LiveQueryWindowBinding`](LiveQueryWindowBinding.md) + +## Properties + +### initialPageParam + +```ts +initialPageParam: number; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:36 + +First result-page label, not a server cursor or remote offset. + +*** + +### pageSize + +```ts +pageSize: number; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:34 diff --git a/docs/reference/interfaces/LiveQueryWindowUpdate.md b/docs/reference/interfaces/LiveQueryWindowUpdate.md new file mode 100644 index 0000000000..175c2d4645 --- /dev/null +++ b/docs/reference/interfaces/LiveQueryWindowUpdate.md @@ -0,0 +1,90 @@ +--- +id: LiveQueryWindowUpdate +title: LiveQueryWindowUpdate +--- + +Defined in: packages/db/src/live-query-window-binding.ts:72 + +Validated infinite-query page shape. + +## Extends + +- [`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md) + +## Properties + +### dependencies? + +```ts +optional dependencies: readonly unknown[]; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:80 + +The query input's identity dependencies. + +*** + +### initialPageParam + +```ts +initialPageParam: number; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:36 + +First result-page label, not a server cursor or remote offset. + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`initialPageParam`](LiveQueryWindowPageShape.md#initialpageparam) + +*** + +### input + +```ts +input: unknown; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:78 + +A supplied live-query Collection, or a callback that returns the query. +The binding calls the callback only when it compiles a new window +collection, except in `always` mode, where it calls it on every update. + +*** + +### pageSize + +```ts +pageSize: number; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:34 + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`pageSize`](LiveQueryWindowPageShape.md#pagesize) + +*** + +### recompile + +```ts +recompile: LiveQueryWindowRecompile; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:83 + +*** + +### scope? + +```ts +optional scope: unknown; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:82 + +Context compared by reference, such as the `DbClient` a query binds to. diff --git a/docs/reference/interfaces/PersistedInitialRenderInput.md b/docs/reference/interfaces/PersistedInitialRenderInput.md new file mode 100644 index 0000000000..9beff8ce0f --- /dev/null +++ b/docs/reference/interfaces/PersistedInitialRenderInput.md @@ -0,0 +1,118 @@ +--- +id: PersistedInitialRenderInput +title: PersistedInitialRenderInput +--- + +Defined in: [packages/db/src/persisted-readiness.ts:33](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L33) + +**`Internal`** + +What a live query observer passes to the initial-render wait. + +## Properties + +### clientQuery + +```ts +clientQuery: InitialRenderClientQuery | undefined; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:41](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L41) + +The client query stream at the start of the wait, if any. + +*** + +### collectionStatus() + +```ts +collectionStatus: () => string | undefined; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:45](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L45) + +The live query Collection's status now. + +#### Returns + +`string` \| `undefined` + +*** + +### currentClientQuery() + +```ts +currentClientQuery: () => unknown; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L43) + +The client query stream now, for a failed stream's identity. + +#### Returns + +`unknown` + +*** + +### network + +```ts +network: Promise; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:39](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L39) + +The network preload that this render prefers. + +*** + +### onReady() + +```ts +onReady: () => void; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L48) + +Runs once the render may proceed, before the promise resolves. + +#### Returns + +`void` + +*** + +### readPersisted() + +```ts +readPersisted: () => object; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:46](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L46) + +#### Returns + +`object` + +##### error + +```ts +error: unknown; +``` + +##### status + +```ts +status: string; +``` + +*** + +### sources + +```ts +sources: readonly object[]; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L34) diff --git a/docs/reference/interfaces/PersistedReadinessSource.md b/docs/reference/interfaces/PersistedReadinessSource.md index af76af3e36..2de10bd6ed 100644 --- a/docs/reference/interfaces/PersistedReadinessSource.md +++ b/docs/reference/interfaces/PersistedReadinessSource.md @@ -3,7 +3,7 @@ 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) +Defined in: [packages/db/src/persisted-readiness.ts:10](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L10) **`Internal`** @@ -17,7 +17,7 @@ Capability supplied by a persistence adapter, not by core sync. 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) +Defined in: [packages/db/src/persisted-readiness.ts:14](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L14) Anchor the deadline to this Collection's current sync run. @@ -33,7 +33,7 @@ Anchor the deadline to this Collection's current sync run. 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) +Defined in: [packages/db/src/persisted-readiness.ts:15](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L15) #### Returns @@ -47,7 +47,7 @@ Defined in: [packages/db/src/persisted-readiness.ts:13](https://github.com/TanSt 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) +Defined in: [packages/db/src/persisted-readiness.ts:12](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L12) Time to prefer network before a completed persisted restore may render. @@ -59,7 +59,7 @@ Time to prefer network before a completed persisted restore may render. 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) +Defined in: [packages/db/src/persisted-readiness.ts:16](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L16) #### Parameters @@ -76,3 +76,47 @@ Defined in: [packages/db/src/persisted-readiness.ts:14](https://github.com/TanSt ##### Returns `void` + +*** + +### waitForInitialRender() + +```ts +waitForInitialRender: (__namedParameters) => object; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:22](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L22) + +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. + +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. + +#### Parameters + +##### \_\_namedParameters + +[`PersistedInitialRenderInput`](PersistedInitialRenderInput.md) + +#### Returns + +`object` + +##### cancel() + +```ts +cancel: () => void; +``` + +###### Returns + +`void` + +##### promise + +```ts +promise: Promise; +``` diff --git a/docs/reference/type-aliases/LiveQuerySourceIdClaims.md b/docs/reference/type-aliases/LiveQuerySourceIdClaims.md new file mode 100644 index 0000000000..019ed75f0c --- /dev/null +++ b/docs/reference/type-aliases/LiveQuerySourceIdClaims.md @@ -0,0 +1,13 @@ +--- +id: LiveQuerySourceIdClaims +title: LiveQuerySourceIdClaims +--- + +```ts +type LiveQuerySourceIdClaims = WeakMap>; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L93) + +The source object each Collection ID named while one binding is mounted, +per client scope. diff --git a/docs/reference/type-aliases/LiveQueryWindowCollection.md b/docs/reference/type-aliases/LiveQueryWindowCollection.md deleted file mode 100644 index 15f55ce235..0000000000 --- a/docs/reference/type-aliases/LiveQueryWindowCollection.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -id: LiveQueryWindowCollection -title: LiveQueryWindowCollection ---- - -```ts -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) - -**`Internal`** - -Shared adapter view of a collection with an ordered window. - -## Type Declaration - -### utils - -```ts -utils: object; -``` - -#### utils.getWindow() - -```ts -getWindow: () => LiveQueryWindow | undefined; -``` - -##### Returns - -`LiveQueryWindow` \| `undefined` - -#### utils.setWindow() - -```ts -setWindow: (options) => WindowResult; -``` - -##### Parameters - -###### options - -`LiveQueryWindow` - -##### Returns - -`WindowResult` diff --git a/docs/reference/type-aliases/LiveQueryWindowInputKind.md b/docs/reference/type-aliases/LiveQueryWindowInputKind.md deleted file mode 100644 index 47209e8422..0000000000 --- a/docs/reference/type-aliases/LiveQueryWindowInputKind.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -id: LiveQueryWindowInputKind -title: LiveQueryWindowInputKind ---- - -```ts -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) diff --git a/docs/reference/type-aliases/LiveQueryWindowRecompile.md b/docs/reference/type-aliases/LiveQueryWindowRecompile.md new file mode 100644 index 0000000000..530be6c4e3 --- /dev/null +++ b/docs/reference/type-aliases/LiveQueryWindowRecompile.md @@ -0,0 +1,19 @@ +--- +id: LiveQueryWindowRecompile +title: LiveQueryWindowRecompile +--- + +```ts +type LiveQueryWindowRecompile = "identity" | "dependencies" | "always"; +``` + +Defined in: packages/db/src/live-query-window-binding.ts:70 + +When a query input compiles again. + +- `identity`: when its identity dependencies change by structure, as for a + derived query identity or a `queryKey`. +- `dependencies`: when a dependency changes by reference, as for a + dependency list. +- `always`: on every update, as for a reactive rerun, which happens only + when something the query reads changed. diff --git a/docs/reference/type-aliases/PersistedReadinessSnapshot.md b/docs/reference/type-aliases/PersistedReadinessSnapshot.md index ab8ec1d61a..be0fb73fd6 100644 --- a/docs/reference/type-aliases/PersistedReadinessSnapshot.md +++ b/docs/reference/type-aliases/PersistedReadinessSnapshot.md @@ -19,6 +19,6 @@ type PersistedReadinessSnapshot = }; ``` -Defined in: [packages/db/src/persisted-readiness.ts:2](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L2) +Defined in: [packages/db/src/persisted-readiness.ts:4](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L4) 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 deleted file mode 100644 index 97f3750609..0000000000 --- a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: ResolvedLiveQueryWindowInput -title: ResolvedLiveQueryWindowInput ---- - -```ts -type ResolvedLiveQueryWindowInput = - | { - collection: Collection; - kind: "collection"; -} - | { - kind: "query"; - query: QueryBuilder; -}; -``` - -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) - -**`Internal`** - -The supported, enabled input forms for infinite-query adapters. - -## Type Parameters - -### TContext - -`TContext` *extends* [`Context`](../interfaces/Context.md) diff --git a/docs/reference/variables/PERSISTED_READINESS.md b/docs/reference/variables/PERSISTED_READINESS.md index 9867fc89cb..2bafd57ff6 100644 --- a/docs/reference/variables/PERSISTED_READINESS.md +++ b/docs/reference/variables/PERSISTED_READINESS.md @@ -7,7 +7,7 @@ title: PERSISTED_READINESS 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) +Defined in: [packages/db/src/persisted-readiness.ts:158](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L158) **`Internal`** diff --git a/domain_map.yaml b/domain_map.yaml index 20997e9002..1d91bc7abf 100644 --- a/domain_map.yaml +++ b/domain_map.yaml @@ -291,6 +291,9 @@ skills: - name: 'React' package: '@tanstack/react-db' config_surface: 'useLiveQuery, useLiveSuspenseQuery, useLiveInfiniteQuery, usePacedMutations' + - name: 'Preact' + package: '@tanstack/preact-db' + config_surface: 'useLiveQuery, useLiveInfiniteQuery with derived query identity' - name: 'Vue' package: '@tanstack/vue-db' config_surface: 'useLiveQuery composable with MaybeRefOrGetter' diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 5d13b707d4..ac0a5521fe 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -62,10 +62,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-angular-${sourceSeq++}`, + id: collectionId ?? `conformance-angular-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -231,6 +232,8 @@ function mountControllable

( const angularDriver: LiveQueryDriver = { name: `angular`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 7ced1918a7..221be72d63 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -1173,3 +1173,24 @@ export class UnhashableQueryIRError extends Error { this.name = `UnhashableQueryIRError` } } + +/** + * Error thrown when a mounted framework binding that compares query identity + * reads a different source Collection under a Collection ID it has already + * read, in an earlier query or the same one. The identity names each source by + * its ID, so the binding could otherwise keep a live query that reads the old + * source. + */ +export class SourceCollectionIdReuseError extends TanStackDBError { + constructor( + public readonly bindingName: string, + public readonly collectionId: string, + ) { + super( + devBuild() && process.env.NODE_ENV !== `production` + ? `[${bindingName}] Collection ID "${collectionId}" named a different source Collection than one this live query already read. While the hook is mounted, each Collection ID must keep naming one source Collection. Unmount the hook and clean up its previous source and client scope before reusing the ID.` + : codedMessage(245, { bindingName, collectionId }), + ) + this.name = `SourceCollectionIdReuseError` + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 00e18d9dda..51a4976462 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -14,13 +14,11 @@ export * from './types' export * from './proxy' export * from './query/index.js' export * from './optimistic-action' +export type { LiveQueryKey, LiveQueryOptions } from './live-query-options' +/** @internal Unstable layer for the first-party framework adapters. */ 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' export * from './local-only' export * from './local-storage' export { diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index 6979829a25..69a26c65d5 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -1,81 +1,164 @@ -import type { Collection } from './collection/index.js' -import type { CollectionStatus } from './types.js' +import { SourceCollectionIdReuseError } from './errors.js' +import { isCollection } from './live-query-primitives.js' +import { + getPreparedLiveQueryIdentity, + getPreparedLiveQuerySources, + rejectLiveQueryInput, +} from './live-query-options.js' +import type { DbClient } from './client.js' +import type { + DeferredLiveQueryCollections, + LiveQueryKey, +} from './live-query-options.js' /** - * Shared helpers for the first-party framework adapters (`@tanstack/react-db`, - * `@tanstack/vue-db`, `@tanstack/svelte-db`, `@tanstack/solid-db`, - * `@tanstack/angular-db`). + * The adapter layer: what the first-party framework adapters + * (`@tanstack/react-db`, `@tanstack/preact-db`, `@tanstack/vue-db`, + * `@tanstack/svelte-db`, `@tanstack/solid-db`, `@tanstack/angular-db`) build + * their bindings from. It centralizes logic every adapter used to duplicate, + * so they stay consistent across frameworks. * - * These centralize small pieces of logic every adapter used to duplicate, so - * they stay consistent across frameworks. They are intended for the official - * adapters; treat them as unstable for external use. + * Application code should use an adapter instead. Everything exported here is + * unstable for external use and changes with the adapters that share it. */ +export * from './live-query-primitives.js' +export * from './live-query-observer.js' +export * from './live-query-window-binding.js' +export { + getLiveQueryHash, + getPreparedLiveQueryIdentity, + getPreparedLiveQuerySources, + prepareLiveQueryValue, + resolveLiveQueryValue, + resumeDeferredLiveQueryCollections, +} from './live-query-options.js' +export type { DeferredLiveQueryCollections } from './live-query-options.js' +export { + createLiveQueryWindowController, + fetchNextLiveQueryWindowPage, +} from './live-query-window-controller.js' +export type { + CreateLiveQueryWindowControllerOptions, + LiveQueryWindowController, + LiveQueryWindowSnapshot, +} from './live-query-window-controller.js' + /** - * Structural check for a live-query/`Collection` instance. + * The `queryKey` of a config-object query input, if it supplies one. An empty + * key is absent, as it is for `getLiveQueryHash`, so a hook and a `DbClient` + * preload agree on the query's identity. + */ +export function getLiveQueryKey(input: unknown): LiveQueryKey | undefined { + const queryKey = + input && typeof input === `object` && !isCollection(input) + ? (input as { queryKey?: unknown }).queryKey + : undefined + return Array.isArray(queryKey) && queryKey.length > 0 ? queryKey : undefined +} + +/* + * Query identity and source Collection ID claims. * - * Uses duck typing rather than `instanceof CollectionImpl` on purpose: adapters - * and core can resolve to different copies of `@tanstack/db` (dual-package / - * multi-realm), where `instanceof` gives false negatives. The three methods - * below uniquely identify a Collection. + * A binding that compares derived query identity decides from it whether to + * compile again: React and Preact compare it on every render, and a `DbClient` + * matches preloaded and hydrated queries by its hash. That identity names each + * source by its Collection ID. While such a binding is mounted, an ID must + * keep naming one source object in each client scope, or the binding could + * reuse a live query that reads the old source. A binding that compiles again + * whenever its reactive reads change compares no identity, so neither check + * applies to it. */ -export function isCollection( - value: unknown, -): value is Collection { - return ( - typeof value === `object` && - value !== null && - typeof (value as any).subscribeChanges === `function` && - typeof (value as any).startSyncImmediate === `function` && - typeof (value as any).id === `string` - ) + +const sourceObjectTokens = new WeakMap() +let nextSourceObjectToken = 0 + +/** A process-unique number for a source object, without retaining it. */ +export function getSourceObjectToken(source: object): number { + let token = sourceObjectTokens.get(source) + if (token === undefined) { + token = ++nextSourceObjectToken + sourceObjectTokens.set(source, token) + } + return token } +// The client scope of a binding that reads no DbClient. +const unscopedClaims = {} + /** - * The Collection an adapter hands users as `collection`. A pooled live query - * stands in for its live-query Collection and builds it only when touched. + * The source object each Collection ID named while one binding is mounted, + * per client scope. */ -export function getPublicCollection< - T extends Collection | null | undefined, ->(collection: T): T { - return ( - (collection as { publicCollection?: T } | null | undefined) - ?.publicCollection ?? collection - ) +export type LiveQuerySourceIdClaims = WeakMap> + +export function createLiveQuerySourceIdClaims(): LiveQuerySourceIdClaims { + return new WeakMap() } -/** Whether a collection yields a single result (`findOne`) rather than an array. */ -export function isSingleResultCollection( - collection: Collection, -): boolean { - return ( - (collection.config as { singleResult?: boolean } | undefined) - ?.singleResult === true - ) +/** Record a query's sources, or throw before recording any. */ +export function claimLiveQuerySourceIds( + claims: LiveQuerySourceIdClaims, + sources: ReadonlyArray<{ id: string }>, + client: DbClient | undefined, + bindingName: string, +): void { + const scope = client ?? unscopedClaims + const prior = claims.get(scope) + const seen = new Map() + for (const source of sources) { + const token = getSourceObjectToken(source) + const previous = seen.get(source.id) ?? prior?.get(source.id) + if (previous !== undefined && previous !== token) { + throw new SourceCollectionIdReuseError(bindingName, source.id) + } + seen.set(source.id, token) + } + const claimed = prior ?? new Map() + for (const [id, token] of seen) claimed.set(id, token) + claims.set(scope, claimed) } -/** The derived boolean status flags every adapter exposes for a query. */ -export interface LiveQueryStatusFlags { - isLoading: boolean - isReady: boolean - isIdle: boolean - isError: boolean - isCleanedUp: boolean +/** Where a binding that compares query identity checks a prepared query. */ +export interface LiveQueryIdentityScope { + /** This binding's claims; omit to check identity without claiming. */ + claims?: LiveQuerySourceIdClaims + client: DbClient | undefined + bindingName: string + /** Sync starts the query's preparation deferred. */ + deferredCollections?: DeferredLiveQueryCollections + /** Report a descriptor source that no client bound. */ + onUnboundDescriptor?: (alias: string) => never } /** - * Derive the boolean status flags from a collection status. Adapters represent - * a disabled query separately (with `isReady: true`); this covers the real - * `CollectionStatus` values. + * Identify a prepared query for a binding that compares query identity. The + * identity throws `UnhashableQueryIRError` for IR without a stable identity, + * then the query's sources claim their Collection IDs. Either rejection + * releases the sync starts the preparation deferred, so a later reader, such + * as another hook over the same Collection, can still start those sources. */ -export function getLiveQueryStatusFlags( - status: CollectionStatus, -): LiveQueryStatusFlags { - return { - isLoading: status === `loading`, - isReady: status === `ready`, - isIdle: status === `idle`, - isError: status === `error`, - isCleanedUp: status === `cleaned-up`, +export function identifyLiveQuery( + preparedValue: unknown, + scope: LiveQueryIdentityScope, + identify: ( + preparedValue: unknown, + ) => TIdentity = getPreparedLiveQueryIdentity as ( + preparedValue: unknown, + ) => TIdentity, +): TIdentity { + try { + const identity = identify(preparedValue) + if (scope.claims) { + claimLiveQuerySourceIds( + scope.claims, + getPreparedLiveQuerySources(preparedValue, scope.onUnboundDescriptor), + scope.client, + scope.bindingName, + ) + } + return identity + } catch (error) { + return rejectLiveQueryInput(scope.deferredCollections, error) } } diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 3e8e15a6c8..c907d089ed 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -3,7 +3,7 @@ import { getLiveQueryStatusFlags, getPublicCollection, isSingleResultCollection, -} from './live-query-adapter.js' +} from './live-query-primitives.js' import { getBuilderFromConfig } from './query/live/collection-registry.js' import { getPooledLiveQuerySource } from './query/pooled-live-query.js' import { getPersistedReadinessSource } from './persisted-readiness.js' diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 25abc0d25c..86edc8044a 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -1,6 +1,6 @@ import { BaseQueryBuilder } from './query/builder/index.js' import { collectSourceRefs, requireCollectionSource } from './query/ir.js' -import { isCollection } from './live-query-adapter.js' +import { isCollection } from './live-query-primitives.js' import { createLiveQueryCollection } from './query/live-query-collection.js' import { createPooledLiveQuery, @@ -32,6 +32,23 @@ export type DeferredLiveQueryCollections = Set< CollectionImpl > +/** + * Release the sync starts a rejected query input deferred, then rethrow its + * rejection. As for a `DbClient` preload, the rejection wins over a source + * whose resumed start fails; that source records its own failure. + */ +export function rejectLiveQueryInput( + collections: DeferredLiveQueryCollections | undefined, + rejection: unknown, +): never { + try { + if (collections) resumeDeferredLiveQueryCollections(collections) + } catch { + // Report the rejection, not a later start failure. + } + throw rejection +} + /** Release every deferred source, then report the first startup failure. */ export function resumeDeferredLiveQueryCollections( collections: DeferredLiveQueryCollections, @@ -86,13 +103,31 @@ function createCollectionResolver( } } +/** + * Bind a query input's descriptor sources to `dbClient` and invoke its query + * callback. Sources materialized for a render defer their sync start into + * `deferredCollections`; a preparation that throws releases them, so a later + * reader can still start those sources. + */ export function prepareLiveQueryValue( value: unknown, dbClient: DbClient | undefined, deferredCollections?: DeferredLiveQueryCollections, +): unknown { + try { + return prepareValue(value, dbClient, deferredCollections) + } catch (error) { + return rejectLiveQueryInput(deferredCollections, error) + } +} + +function prepareValue( + value: unknown, + dbClient: DbClient | undefined, + deferredCollections?: DeferredLiveQueryCollections, ): unknown { if (typeof value === `function`) { - return prepareLiveQueryValue( + return prepareValue( value(createInitialQueryBuilder()), dbClient, deferredCollections, @@ -119,11 +154,7 @@ export function prepareLiveQueryValue( ...config } = value as PreparedLiveQueryConfigInput - const preparedQuery = prepareLiveQueryValue( - query, - dbClient, - deferredCollections, - ) + const preparedQuery = prepareValue(query, dbClient, deferredCollections) if (preparedQuery === undefined || preparedQuery === null) { return preparedQuery @@ -203,12 +234,6 @@ export function getLiveQueryHash( return getStableValueHash(identity, `queryKey`) } -/** - * Resolve an adapter's query value to what its observer watches: `null` for a - * disabled query, an existing Collection with sync started, or a live query. - * A query builder whose shape a shared partition can serve gets a pooled view - * instead of its own live-query Collection. - */ // A config that names only its query and lifetime describes the same live // query as its builder. Any other option shapes its Collection, so compiles. function poolConfig( @@ -226,6 +251,12 @@ function poolConfig( }) } +/** + * Resolve an adapter's query value to what its observer watches: `null` for a + * disabled query, an existing Collection with sync started, or a live query. + * A query builder whose shape a shared partition can serve gets a pooled view + * instead of its own live-query Collection. + */ export function resolveLiveQueryValue( value: unknown, { diff --git a/packages/db/src/live-query-primitives.ts b/packages/db/src/live-query-primitives.ts new file mode 100644 index 0000000000..a2436f9ac9 --- /dev/null +++ b/packages/db/src/live-query-primitives.ts @@ -0,0 +1,74 @@ +import type { Collection } from './collection/index.js' +import type { CollectionStatus } from './types.js' + +// Collection guards and status flags. Core modules import them from here, and +// the adapter layer (`live-query-adapter.ts`) re-exports them. + +/** + * Structural check for a live-query/`Collection` instance. + * + * Uses duck typing rather than `instanceof CollectionImpl` on purpose: adapters + * and core can resolve to different copies of `@tanstack/db` (dual-package / + * multi-realm), where `instanceof` gives false negatives. The three methods + * below uniquely identify a Collection. + */ +export function isCollection( + value: unknown, +): value is Collection { + return ( + typeof value === `object` && + value !== null && + typeof (value as any).subscribeChanges === `function` && + typeof (value as any).startSyncImmediate === `function` && + typeof (value as any).id === `string` + ) +} + +/** + * The Collection an adapter hands users as `collection`. A pooled live query + * stands in for its live-query Collection and builds it only when touched. + */ +export function getPublicCollection< + T extends Collection | null | undefined, +>(collection: T): T { + return ( + (collection as { publicCollection?: T } | null | undefined) + ?.publicCollection ?? collection + ) +} + +/** Whether a collection yields a single result (`findOne`) rather than an array. */ +export function isSingleResultCollection( + collection: Collection, +): boolean { + return ( + (collection.config as { singleResult?: boolean } | undefined) + ?.singleResult === true + ) +} + +/** The derived boolean status flags every adapter exposes for a query. */ +export interface LiveQueryStatusFlags { + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean +} + +/** + * Derive the boolean status flags from a collection status. Adapters represent + * a disabled query separately (with `isReady: true`); this covers the real + * `CollectionStatus` values. + */ +export function getLiveQueryStatusFlags( + status: CollectionStatus, +): LiveQueryStatusFlags { + return { + isLoading: status === `loading`, + isReady: status === `ready`, + isIdle: status === `idle`, + isError: status === `error`, + isCleanedUp: status === `cleaned-up`, + } +} diff --git a/packages/db/src/live-query-window-binding.ts b/packages/db/src/live-query-window-binding.ts new file mode 100644 index 0000000000..25a4f646b8 --- /dev/null +++ b/packages/db/src/live-query-window-binding.ts @@ -0,0 +1,277 @@ +import { codedMessage, devBuild } from './error-message.js' +import { createLiveQueryCollection } from './query/live-query-collection.js' +import { + assertLiveQueryWindowManyResult, + compareLiveQueryWindowDependencies, + createLiveQueryWindowController, + getLiveQueryWindowCollectionWarning, + getLiveQueryWindowInputKind, + liveQueryWindowMatches, + normalizeLiveQueryWindowPageSize, + resolveLiveQueryWindowInput, + shouldPreserveLiveQueryWindowPageCount, +} from './live-query-window-controller.js' +import type { Collection } from './collection/index.js' +import type { LiveQueryWindowController } from './live-query-window-controller.js' + +/** + * Window binding: what one mounted infinite-query hook keeps between updates. + * + * Every framework adapter's `useLiveInfiniteQuery` reaches the same decision + * on each render or reactive rerun. It keeps the binding when nothing changed, + * replaces only the window controller when the page shape changed, or binds a + * new window collection when the input changed. A new controller keeps the + * loaded page count when the old pages still describe the new query. + * Adapters own only their framework's subscription and disposal of + * `controller`. + */ + +// Live queries created here are cleaned up immediately (0 disables GC). +const WINDOW_COLLECTION_GC_TIME_MS = 1 + +/** Validated infinite-query page shape. */ +export interface LiveQueryWindowPageShape { + pageSize: number + /** First result-page label, not a server cursor or remote offset. */ + initialPageParam: number +} + +/** + * Read an infinite-query config's page shape. `getNextPageParam` is rejected: + * the hook widens an ordered live query and has no server cursor to advance. + */ +export function readLiveQueryWindowPageShape(config: { + pageSize?: number + initialPageParam?: number +}): LiveQueryWindowPageShape { + if (`getNextPageParam` in config) { + throw new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.` + : codedMessage(246), + ) + } + return { + pageSize: normalizeLiveQueryWindowPageSize(config.pageSize), + initialPageParam: config.initialPageParam ?? 0, + } +} + +/** + * When a query input compiles again. + * + * - `identity`: when its identity dependencies change by structure, as for a + * derived query identity or a `queryKey`. + * - `dependencies`: when a dependency changes by reference, as for a + * dependency list. + * - `always`: on every update, as for a reactive rerun, which happens only + * when something the query reads changed. + */ +export type LiveQueryWindowRecompile = `identity` | `dependencies` | `always` + +export interface LiveQueryWindowUpdate extends LiveQueryWindowPageShape { + /** + * A supplied live-query Collection, or a callback that returns the query. + * The binding calls the callback only when it compiles a new window + * collection, except in `always` mode, where it calls it on every update. + */ + input: unknown + /** The query input's identity dependencies. */ + dependencies?: ReadonlyArray + /** Context compared by reference, such as the `DbClient` a query binds to. */ + scope?: unknown + recompile: LiveQueryWindowRecompile +} + +export interface LiveQueryWindowBinding extends LiveQueryWindowPageShape { + /** The supplied Collection; `undefined` for a query input. */ + supplied: Collection | undefined + dependencies: ReadonlyArray | undefined + scope: unknown + collection: Collection + /** Whether this binding created `collection` from a query. */ + ownsCollection: boolean + controller: LiveQueryWindowController + /** + * A developer hint about a newly bound supplied collection's window, for the + * adapter to print once. Production builds carry none. + */ + warning: string | undefined +} + +/** + * Bind an infinite-query update. Returns `previous` when it still serves the + * update; otherwise returns a new binding whose controller the adapter + * subscribes and later disposes. + * + * `uncommitted` is a binding built by an earlier update that never took + * effect, such as a React render that did not commit. When it answers this + * update, its window collection is reused instead of compiling another. + */ +export function bindLiveQueryWindow( + previous: LiveQueryWindowBinding | null, + update: LiveQueryWindowUpdate, + uncommitted?: LiveQueryWindowBinding | null, +): LiveQueryWindowBinding { + // A reactive rerun resolves its input first: a getter may return a + // supplied Collection. + const resolved = + update.recompile === `always` + ? resolveLiveQueryWindowInput(update.input) + : undefined + const supplied = + resolved === undefined + ? getLiveQueryWindowInputKind(update.input) === `collection` + ? (update.input as Collection) + : undefined + : resolved.kind === `collection` + ? resolved.collection + : undefined + const inputKind = supplied ? `collection` : `query` + const previousKind = previous + ? previous.supplied + ? `collection` + : `query` + : undefined + const dependencies = supplied ? undefined : (update.dependencies ?? []) + + const change = compareUpdate(previous, update, dependencies) + const needsNewCollection = + previous === null || + previousKind !== inputKind || + (supplied ? previous.supplied !== supplied : change.recompile) + const pageShapeChanged = + previous === null || + previous.pageSize !== update.pageSize || + previous.initialPageParam !== update.initialPageParam + if (!needsNewCollection && !pageShapeChanged) return previous + + const initialPageCount = shouldPreserveLiveQueryWindowPageCount({ + hasPreviousController: previous !== null, + previousInputKind: previousKind, + inputKind, + sameCollection: supplied !== undefined && previous?.supplied === supplied, + dependenciesChanged: change.dependenciesChanged, + dependenciesStructurallyEqual: change.dependenciesStructurallyEqual, + pageShapeChanged, + }) + ? Math.max(1, previous!.controller.getSnapshot().pages.length) + : 1 + // The peek-ahead window for every retained page. + const requiredLimit = initialPageCount * update.pageSize + 1 + + let collection = previous?.collection + let ownsCollection = previous?.ownsCollection ?? false + let warning: string | undefined + if (needsNewCollection) { + if ( + !supplied && + uncommitted?.ownsCollection && + !compareUpdate(uncommitted, update, dependencies).recompile && + uncommitted.collection.status !== `cleaned-up` && + uncommitted.collection.status !== `error` && + liveQueryWindowMatches(uncommitted.collection, requiredLimit) + ) { + // Nothing has taken effect since the earlier update built this + // collection, and its window is exactly the retained pages, so its first + // rows are correct. A collection in terminal error, such as after its + // source restarted, cannot serve the query again. + collection = uncommitted.collection + ownsCollection = true + } else { + const window = openWindowCollection( + resolved ?? resolveLiveQueryWindowInput(update.input), + update.pageSize, + requiredLimit, + ) + collection = window.collection + ownsCollection = window.ownsCollection + warning = window.warning + } + } + + return { + supplied, + dependencies, + scope: update.scope, + pageSize: update.pageSize, + initialPageParam: update.initialPageParam, + collection: collection!, + ownsCollection, + controller: createLiveQueryWindowController(collection, { + pageSize: update.pageSize, + initialPageParam: update.initialPageParam, + initialPageCount, + }), + warning, + } +} + +/** How an update's query identity differs from a binding's. */ +function compareUpdate( + binding: LiveQueryWindowBinding | null, + update: LiveQueryWindowUpdate, + dependencies: ReadonlyArray | undefined, +): { + recompile: boolean + dependenciesChanged: boolean + dependenciesStructurallyEqual: boolean +} { + const comparison = compareLiveQueryWindowDependencies( + binding?.dependencies, + dependencies ?? [], + ) + const scopeChanged = binding?.scope !== update.scope + const dependenciesChanged = + scopeChanged || + (update.recompile === `identity` + ? !comparison.structurallyEqual + : comparison.changed) + return { + recompile: update.recompile === `always` || dependenciesChanged, + dependenciesChanged, + // An identity that changed differs by structure by definition. + dependenciesStructurallyEqual: + !scopeChanged && + update.recompile !== `identity` && + comparison.structurallyEqual, + } +} + +/** + * Bind the window collection for a resolved input, starting its sync now when + * its first published rows are already correct. A created collection's window + * covers every retained page, so it never publishes fewer rows than the + * controller's pages; the controller grows it with `setWindow`. A supplied + * window that the controller must still adjust waits for the subscription. + */ +function openWindowCollection( + input: ReturnType>, + pageSize: number, + requiredLimit: number, +): { + collection: Collection + ownsCollection: boolean + warning: string | undefined +} { + if (input.kind === `collection`) { + const warning = getLiveQueryWindowCollectionWarning( + input.collection, + pageSize + 1, + ) + if (liveQueryWindowMatches(input.collection, requiredLimit)) { + input.collection.startSyncImmediate() + } + return { collection: input.collection, ownsCollection: false, warning } + } + const collection = createLiveQueryCollection({ + query: input.query.limit(requiredLimit).offset(0), + gcTime: WINDOW_COLLECTION_GC_TIME_MS, + }) + assertLiveQueryWindowManyResult(collection) + // Like useLiveQuery, start sync during render once the query is valid. A + // render reads only rows the sources already hold, and sends no request + // until the subscription attaches. + collection.startSyncImmediate() + return { collection, ownsCollection: true, warning: undefined } +} diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 98b5a191cc..ee804a739b 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -6,7 +6,7 @@ import { getLiveQueryStatusFlags, isCollection, isSingleResultCollection, -} from './live-query-adapter.js' +} from './live-query-primitives.js' import { createLiveQueryObserver } from './live-query-observer.js' import { BaseQueryBuilder } from './query/builder/index.js' import { deepEquals } from './utils.js' diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 92075b4187..7351831f45 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -6,7 +6,7 @@ * errors, enablement, recompilation, and teardown. Each adapter owns only the * bridge from its native scheduler into the small `LiveQueryDriver` language. * Running one shared scenario through every bridge separates engine semantics - * from React, Vue, Solid, Angular, and Svelte timing. + * from React, Preact, Vue, Solid, Angular, and Svelte timing. * * Realm safety: the driver — not the scenarios — creates source collections and * supplies query operators, both imported from the *adapter's* copy of @@ -19,6 +19,38 @@ * visible after the driver's documented flush cut. Result helpers read raw * fields and selected rows; they do not repair malformed adapter output. * + * Query identity (1.0 law, maintainer decisions 2026-10-22 and 2026-10-23). + * The law depends on how a binding decides to compile again. + * + * A binding that compares query identity re-runs its query input and compiles + * again only when the input's identity changed. React and Preact compare it + * on every render. Such a binding compiles an input that has no `queryKey` + * only when the query IR has a stable identity; otherwise it throws + * `UnhashableQueryIRError`. The identity names each source by its Collection + * ID, so while the binding is mounted each claimed ID keeps naming one source + * object: a different object under a claimed ID, in a later query or in the + * same one, throws + * `SourceCollectionIdReuseError` before any row from either source is + * returned. Revisiting the same object is legal, and a new binding starts with + * no claims. An input with a nonempty `queryKey` owns its identity and skips + * both checks; an empty key is absent, as it is for a `DbClient` preload. + * + * A binding that compiles again whenever its reactive reads change (Vue, + * Solid, Angular, and Svelte without a `DbClient`) compares no identity. It + * compiles any query input, including one without a stable IR identity, and + * its rows follow the current input: after a source swap under the same + * Collection ID, it publishes the new source's rows and never the old ones, + * and one query over two same-ID objects reads each of them. + * A `DbClient` matches queries by identity hash, so a binding backed by one + * follows the comparing law; that boundary has its own receiving witnesses. + * + * Rejection is observed at the adapter's native compile point. The driver + * surfaces it as a throw from `mount*`, a rejected `setParam`, or a throw from + * the next `current()` read. After a rejection, no later read exposes rows: + * each `current()` throws or holds none, so a framework whose memo throws once + * cannot fall back to the previous query's rows. Each driver releases any + * native scope that a rejected mount acquired. + * * All current scenarios must pass. A future known bug needs an independently * approved exact failure signature; a whole-test waiver could hide another bug. */ @@ -140,6 +172,7 @@ export interface LiveQueryHandle { * new value and settles. */ export interface ControllableHandle

extends LiveQueryHandle { + /** Rejects when the adapter rejects the reconfigured query input. */ setParam: (param: P) => Promise } @@ -160,9 +193,13 @@ export interface LiveQueryDriver { disabledRepresentation: `absent` | `empty-reactive` /** Operators from the adapter's `@tanstack/db` realm. */ ops: DbOps - /** Create a realm-correct source collection + mutators, keyed by `id`. */ + /** + * Create a realm-correct source collection + mutators, keyed by `id`. Pass + * `collectionId` to create another source object under an existing ID. + */ makeSource: ( initialData: ReadonlyArray, + collectionId?: string, ) => SourceHandle /** Create a source that starts `loading` and readies on demand (keyed by `id`). */ makeDeferredSource: () => DeferredSourceHandle @@ -197,10 +234,16 @@ export interface LiveQueryDriver { /** Mount via the config-object input form (`{ query: build }`). */ mountConfig: ( build: QueryBuild, - opts?: { gcTime?: number }, + opts?: { gcTime?: number; queryKey?: ReadonlyArray }, ) => LiveQueryHandle /** Mount an explicitly-disabled query (adapter's own null/undefined form). */ mountDisabled: () => LiveQueryHandle + /** + * Whether the binding compares query identity to decide when to compile + * again (see the opening query-identity law). A public adapter property, not + * inferred from observed output. + */ + comparesQueryIdentity: boolean /** No active waivers. Kept empty in drivers to make this boundary explicit. */ knownGaps?: ReadonlyArray /** diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index aa8a1f37fc..d6a5130fb3 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -30,6 +30,13 @@ * framework's own publication cut. Pre-commit work, such as a React render that * never commits, belongs to the drivers: Vue's setup is its commit, so that cut * has no shared form. + * + * The query-identity laws in `contract.ts` apply to infinite bindings too. A + * binding that compares query identity needs `queryKey` for an unhashable + * query and rejects a different source object under a Collection ID it + * already read; rejection surfaces as a throw from the mount, `setParamSync`, + * the next `flush`, or `current()`. A reactive binding compiles either input + * and publishes the current source's pages. */ import type { Collection, LiveQueryWindowController } from '@tanstack/db' import type { QueryBuild, SourceHandle } from './contract' @@ -37,6 +44,8 @@ import type { QueryBuild, SourceHandle } from './contract' export interface InfiniteQueryConfig { pageSize?: number initialPageParam?: number + /** Explicit identity for a query whose IR has no stable hash. */ + queryKey?: ReadonlyArray } export interface InfiniteQueryResult { @@ -99,9 +108,13 @@ export interface InfiniteQueryInputHandle extends InfiniteQueryHandle { export interface InfiniteQueryDriver { name: string + /** Whether the binding compares query identity; see `contract.ts`. */ + comparesQueryIdentity: boolean gt: (a: any, b: any) => any + /** Pass `collectionId` to create another source object under an existing ID. */ makeSource: ( initialData: ReadonlyArray, + collectionId?: string, ) => SourceHandle makeOnDemandSource: ( data: ReadonlyArray, diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 0cb95e8f33..54e1da2437 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -146,6 +146,18 @@ async function captureError(fn: () => InfiniteQueryHandle): Promise { } } +/** A rejected input throws at mount, reconfiguration, flush, or read. */ +async function captureRejection( + run: () => Promise | unknown, +): Promise { + try { + await run() + return undefined + } catch (error) { + return error + } +} + async function waitFor(check: () => boolean): Promise { for (let attempt = 0; attempt < 20; attempt++) { if (check()) return @@ -179,7 +191,8 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { } const driver: InfiniteQueryDriver = { ...rawDriver, - makeSource: (data) => sources!.track(rawDriver.makeSource(data)), + makeSource: (data, collectionId) => + sources!.track(rawDriver.makeSource(data, collectionId)), makeOnDemandSource: (data, delay) => sources!.track(rawDriver.makeOnDemandSource(data, delay)), makeLaggingOnDemandSource: (data) => @@ -1655,8 +1668,98 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { }, ) + // ---- query identity (1.0 law; see contract.ts) ---------------------- + // + // Each history branches only on the driver's public identity policy, and + // both branches check pages: a comparing binding rejects, and a reactive + // binding pages the current input. + + // `fn.where` runs a JavaScript predicate, so its IR has no stable hash. + const functionalRanks = (source: { collection: any }) => (q: any) => + q + .from({ items: source.collection }) + .fn.where(({ items }: any) => items.rank > 4) + .orderBy(({ items }: any) => items.rank, `desc`) + + scenario( + `unhashable-query`, + `pages a query without a stable IR identity, needing queryKey only where the binding compares identity`, + async () => { + const source = driver.makeSource(rows(8)) + if (driver.comparesQueryIdentity) { + const error = await captureError(() => + driver.mount(functionalRanks(source), { pageSize: 3 }), + ) + expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + } + const handle = driver.mount( + functionalRanks(source), + driver.comparesQueryIdentity + ? { pageSize: 3, queryKey: [`ranks-above`, 4] } + : { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(0, 4), 3) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `source-id-reuse`, + `rejects or pages a different source under a Collection ID the binding read`, + async () => { + const first = driver.makeSource(rows(4)) + const other = driver.makeSource(rows(2, `o`)) + const reused = driver.makeSource(rows(3, `r`), first.collection.id) + const handle = driver.mountControllable( + (q, source: { collection: any }) => + q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + first, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), rows(4).slice(0, 3), 3) + // A fresh ID and a revisit of the claimed object are legal. + handle.setParamSync(other) + await handle.flush() + expectPageRows(handle.current(), rows(2, `o`), 3) + handle.setParamSync(first) + await handle.flush() + expectPageRows(handle.current(), rows(4).slice(0, 3), 3) + handle.setParamSync(other) + await handle.flush() + if (!driver.comparesQueryIdentity) { + handle.setParamSync(reused) + await handle.flush() + expectPageRows(handle.current(), rows(3, `r`), 3) + return + } + const error = await captureRejection(async () => { + handle.setParamSync(reused) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ name: `SourceCollectionIdReuseError` }) + // After a rejection, every read throws or holds no rows. Two reads + // catch a memo that throws once and then serves its previous value. + for (let read = 0; read < 2; read++) { + let data: unknown + try { + data = handle.current().data + } catch { + continue + } + expect(data).toEqual([]) + } + }, + ) + it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(39) + expect(registry.size).toBe(41) }) }) } diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 6882c980ee..2e9875d466 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -35,6 +35,17 @@ * 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 * inputs, error status, and order-only moves. + * + * The query-identity scenarios (see `contract.ts`) run each history through + * both identity policies. A comparing binding rejects an unhashable query + * without `queryKey` (an empty key counts as none) at mount and at + * recompilation and accepts it with a key; + * a reactive binding runs it and recompiles when a captured value changes. + * The source-ID history distinguishes a remembered claim from a check against + * only the previous query: A(x) -> C(y) -> A(x) is legal, and a later + * C(y) -> B(x) rejects in a comparing binding and reads B in a reactive one. + * A new binding reads B after the first unmounts. Two same-ID objects in one + * query follow the same split: rejected, or each read. */ import { describe, expect, it } from 'vitest' import { waitForPersistedInitialRender } from '@tanstack/db' @@ -73,6 +84,43 @@ async function expectRetired(collection: ConformanceCollection): Promise { .toBe(`cleaned-up`) } +/** + * A rejected query input throws at the adapter's compile point: from the + * mount or reconfiguration, or from the next result read. + */ +async function expectRejection( + name: string, + run: () => Promise | unknown, +): Promise { + let rejection: unknown + try { + await run() + } catch (error) { + rejection = error + } + expect(rejection, `expected ${name}`).toMatchObject({ name }) +} + +/** + * After a rejection, every read throws or holds no rows. Two reads catch a + * memo that throws once and then serves its previous value. + */ +function expectNoRowsAfterRejection(h: LiveQueryHandle): void { + for (let read = 0; read < 2; read++) { + let result: ReturnType + try { + result = h.current() + } catch { + continue + } + const { data, state } = result + expect( + Array.isArray(data) ? data : data === undefined ? [] : [data], + ).toEqual([]) + expect(state?.size ?? 0).toBe(0) + } +} + async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)) } @@ -113,7 +161,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { } const driver: LiveQueryDriver = { ...rawDriver, - makeSource: (data) => sources!.track(rawDriver.makeSource(data)), + makeSource: (data, collectionId) => + sources!.track(rawDriver.makeSource(data, collectionId)), makeDeferredSource: () => sources!.track(rawDriver.makeDeferredSource()), makeLaggingSource: () => @@ -1092,6 +1141,185 @@ export function runSuite(rawDriver: LiveQueryDriver) { }, ) + // ---- Tier 2: query identity (1.0 law; see contract.ts) --------------- + // + // Each scenario runs one history and branches only on the driver's public + // `comparesQueryIdentity` policy: a comparing binding rejects, and a + // reactive binding publishes the current input's rows. Both branches + // check rows, so a reactive binding that kept the old source's rows fails + // as surely as a comparing binding that accepted the swap. + + // `fn.where` runs a JavaScript predicate, so its IR has no stable hash. + const olderThan = (source: { collection: any }, age: number) => (q: any) => + q + .from({ items: source.collection }) + .fn.where(({ items }: any) => items.age > age) + .select(({ items }: any) => ({ id: items.id })) + const idsOf = (rows: ReadonlyArray<{ id: string }>) => + rows.map(({ id }) => ({ id })) + const comparesIdentity = driver.comparesQueryIdentity + + scenario( + `unhashable-query`, + `a query without a stable IR identity needs queryKey only where the binding compares identity`, + async () => { + const source = driver.makeSource(SEED) + const build = olderThan(source, 26) + for (const mount of [ + () => driver.mount(build), + () => driver.mountConfig(build), + ]) { + if (comparesIdentity) { + await expectRejection(`UnhashableQueryIRError`, async () => { + const h = mount() + await h.flush() + return h.current() + }) + } else { + const h = mount() + await h.flush() + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + h.unmount() + } + } + if (comparesIdentity) { + // An empty key is absent, as it is for a DbClient preload, so the + // query still needs a derived identity. + await expectRejection(`UnhashableQueryIRError`, async () => { + const h = driver.mountConfig(build, { queryKey: [] }) + await h.flush() + return h.current() + }) + // A queryKey owns the identity, so the same query runs. + const h = driver.mountConfig(build, { queryKey: [`older-than`, 26] }) + await h.flush() + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + h.unmount() + } + }, + ) + + scenario( + `unhashable-recompile`, + `recompiling to a query without a stable IR identity follows the binding's identity policy`, + async () => { + const source = driver.makeSource(SEED) + // `null` selects the structured query; a number, the functional one. + const h = driver.mountControllable( + (q, age) => + age === null + ? q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })) + : olderThan(source, age)(q), + null, + ) + await h.flush() + expectUnorderedRows(h.current().data, idsOf(SEED)) + if (comparesIdentity) { + await expectRejection(`UnhashableQueryIRError`, async () => { + await h.setParam(26) + return h.current() + }) + expectNoRowsAfterRejection(h) + return + } + // A changed captured value recompiles, though no identity names it. + await h.setParam(26) + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + await h.setParam(32) + expectUnorderedRows(h.current().data, [{ id: `3` }]) + h.unmount() + }, + ) + + scenario( + `source-id-reuse`, + `a different source under a Collection ID the binding read is rejected or read as the new source`, + async () => { + const first = driver.makeSource(SEED) + const other = driver.makeSource([ + { id: `9`, name: `Other`, age: 40, team: `c` }, + ]) + const reused = driver.makeSource( + [{ id: `8`, name: `Reused`, age: 50, team: `d` }], + first.collection.id, + ) + expect(reused.collection.id).toBe(first.collection.id) + expect(reused.collection).not.toBe(first.collection) + const h = driver.mountControllable<{ collection: any }>( + (q, source) => + q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })), + first, + ) + await h.flush() + expectUnorderedRows(h.current().data, idsOf(SEED)) + // A fresh ID and a revisit of the claimed object are legal. + await h.setParam(other) + expectUnorderedRows(h.current().data, [{ id: `9` }]) + await h.setParam(first) + expectUnorderedRows(h.current().data, idsOf(SEED)) + // The claim outlives the query that made it: going through another + // ID first must not let a different object reuse the first ID. + await h.setParam(other) + if (comparesIdentity) { + await expectRejection(`SourceCollectionIdReuseError`, async () => { + await h.setParam(reused) + return h.current() + }) + expectNoRowsAfterRejection(h) + } else { + await h.setParam(reused) + expectUnorderedRows(h.current().data, [{ id: `8` }]) + } + h.unmount() + + // A new binding starts with no claims. + const fresh = driver.mount((q) => + q + .from({ items: reused.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await fresh.flush() + expectUnorderedRows(fresh.current().data, [{ id: `8` }]) + fresh.unmount() + }, + ) + + scenario( + `source-id-reuse-in-one-query`, + `a query that reads two sources under one Collection ID is rejected or reads both`, + async () => { + const first = driver.makeSource(SEED) + const reused = driver.makeSource(SEED.slice(0, 2), first.collection.id) + const build = (q: any) => + q + .from({ left: first.collection }) + .join( + { right: reused.collection }, + ({ left, right }: any) => ops.eq(left.id, right.id), + `inner`, + ) + .select(({ left }: any) => ({ id: left.id })) + if (comparesIdentity) { + await expectRejection(`SourceCollectionIdReuseError`, async () => { + const h = driver.mount(build) + await h.flush() + return h.current() + }) + return + } + const h = driver.mount(build) + await h.flush() + // The inner join matches only rows both objects hold, so a binding + // that read one object for both sides would publish all three. + expectUnorderedRows(h.current().data, idsOf(SEED.slice(0, 2))) + h.unmount() + }, + ) + // ---- Tier 2: optimistic mutation ------------------------------------- scenario( @@ -1271,7 +1499,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(37) + expect(registry.size).toBe(41) }) }) } diff --git a/packages/db/tests/error-sample-arguments.ts b/packages/db/tests/error-sample-arguments.ts index 0f658be69b..194f1ac043 100644 --- a/packages/db/tests/error-sample-arguments.ts +++ b/packages/db/tests/error-sample-arguments.ts @@ -158,6 +158,7 @@ export const errorSampleArguments: Record>> = { // Window errors UnhashableQueryIRError: [['query.fnSelect', 'function select']], + SourceCollectionIdReuseError: [['useLiveQuery', 'todos']], DatabaseRequiredError: [[]], ObjectStoreNotFoundError: [['todos', 'app', ['users', 'posts']]], NameRequiredError: [[]], diff --git a/packages/db/tests/fixtures/error-codes.json b/packages/db/tests/fixtures/error-codes.json index c2f38a1bc9..c7819fc597 100644 --- a/packages/db/tests/fixtures/error-codes.json +++ b/packages/db/tests/fixtures/error-codes.json @@ -90,5 +90,6 @@ "NameRequiredError": 177, "GetKeyRequiredError": 178, "DuplicateTransactionIdError": 233, - "SetWindowFindOneLimitError": 244 + "SetWindowFindOneLimitError": 244, + "SourceCollectionIdReuseError": 245 } diff --git a/packages/db/tests/fixtures/error-messages.json b/packages/db/tests/fixtures/error-messages.json index 0f3054450a..0b9c3fbed1 100644 --- a/packages/db/tests/fixtures/error-messages.json +++ b/packages/db/tests/fixtures/error-messages.json @@ -137,7 +137,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": [ @@ -260,5 +260,8 @@ ], "SetWindowFindOneLimitError": [ "setWindow() cannot change the limit of a findOne() query." + ], + "SourceCollectionIdReuseError": [ + "[useLiveQuery] Collection ID \"todos\" named a different source Collection than one this live query already read. While the hook is mounted, each Collection ID must keep naming one source Collection. Unmount the hook and clean up its previous source and client scope before reusing the ID." ] } diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 4dd1df77c5..f70c579d1b 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -928,5 +928,12 @@ "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"] + }, + "246": { + "file": "live-query-window-binding.ts", + "template": "getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.", + "literals": [ + "getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination." + ] } } diff --git a/packages/preact-db/README.md b/packages/preact-db/README.md new file mode 100644 index 0000000000..d68b158c8a --- /dev/null +++ b/packages/preact-db/README.md @@ -0,0 +1,59 @@ +# @tanstack/preact-db + +Preact hooks for TanStack DB. See [TanStack/db](https://github.com/TanStack/db) for more details. + +## Installation + +```sh +npm install @tanstack/preact-db +``` + +`@tanstack/preact-db` supports Preact 10 and later and ships as ES modules. + +Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration. + +## Usage + +```tsx +import { eq, useLiveQuery } from '@tanstack/preact-db' + +function TodoList({ userId }: { userId?: string }) { + const { data, isLoading } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, + ) + + if (isLoading) return

Loading...
+ return ( +
    + {data?.map((todo) => ( +
  • {todo.text}
  • + ))} +
+ ) +} +``` + +`useLiveQuery` accepts a query callback, a `{ query }` config object, or an +existing live-query Collection. Return `undefined` or `null` from the callback +to disable the query. `useLiveInfiniteQuery` pages an ordered live query. + +The hooks derive each query's identity from its structured IR, so values the +callback captures recompile the query when they change. A query that uses +functional variants such as `fn.where` has no stable IR hash and throws +`UnhashableQueryIRError` unless a config object names it with `queryKey`: + +```ts +const { data } = useLiveQuery({ + queryKey: [`todos-by-text`, search], + query: (q) => + q + .from({ todos: todosCollection }) + .fn.where(({ todos }) => todos.text.includes(search)), +}) +``` + +See the [Preact adapter documentation](https://tanstack.com/db/latest/docs/framework/preact/overview) for more. diff --git a/packages/preact-db/package.json b/packages/preact-db/package.json new file mode 100644 index 0000000000..655b402e0a --- /dev/null +++ b/packages/preact-db/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/preact-db", + "version": "0.0.0", + "description": "Preact integration for @tanstack/db", + "author": "Kyle Mathews", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/preact-db" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "optimistic", + "preact", + "typescript", + "tanstack-intent" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest --run", + "lint": "eslint . --fix" + }, + "type": "module", + "main": "dist/esm/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src", + "skills" + ], + "dependencies": { + "@tanstack/db": "workspace:*" + }, + "peerDependencies": { + "preact": ">=10.0.0" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.2.4", + "preact": "^11.0.1" + } +} diff --git a/packages/preact-db/skills/preact-db/SKILL.md b/packages/preact-db/skills/preact-db/SKILL.md new file mode 100644 index 0000000000..dfbf0f03dc --- /dev/null +++ b/packages/preact-db/skills/preact-db/SKILL.md @@ -0,0 +1,151 @@ +--- +name: preact-db +description: > + Preact bindings for TanStack DB. useLiveQuery hook with query callbacks, + { query, queryKey } config objects, or pre-created collections; identity is + derived from query IR, with no dependency arrays. Conditional queries via + returning undefined/null. useLiveInfiniteQuery for paging an ordered live + query. Functional variants (fn.where) require queryKey. Import from + @tanstack/preact-db (re-exports all of @tanstack/db). +type: framework +library: db +framework: preact +library_version: '0.6.17' +requires: + - db-core +sources: + - 'TanStack/db:docs/framework/preact/overview.md' + - 'TanStack/db:packages/preact-db/src/useLiveQuery.ts' + - 'TanStack/db:packages/preact-db/src/useLiveInfiniteQuery.ts' +--- + +This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns. + +Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration. + +# TanStack DB — Preact + +## Setup + +```tsx +import { eq, not, useLiveQuery } from '@tanstack/preact-db' + +function TodoList() { + const { data, isLoading } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => not(todo.completed)) + .orderBy(({ todo }) => todo.created_at, 'asc'), + ) + + if (isLoading) return
Loading...
+ return ( +
    + {data.map((todo) => ( +
  • {todo.text}
  • + ))} +
+ ) +} +``` + +`@tanstack/preact-db` re-exports everything from `@tanstack/db`. It supports Preact 10+ and ships ES modules only. + +## Hooks + +### useLiveQuery + +```tsx +// Query callback +const { data, state, status, isReady, isEnabled } = useLiveQuery((q) => + q.from({ todo: todoCollection }), +) + +// Config object (required for queryKey) +const { data } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + gcTime: 60_000, +}) + +// Pre-created live query collection +const { data } = useLiveQuery(activeTodosCollection) + +// Disabled until an input exists: data and state are undefined +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.userId, userId)) + : undefined, +) +``` + +### useLiveInfiniteQuery + +```tsx +const { data, pages, fetchNextPage, hasNextPage, isFetchingNextPage } = + useLiveInfiniteQuery( + (q) => + q + .from({ post: postsCollection }) + .orderBy(({ post }) => post.createdAt, 'desc'), + { pageSize: 20 }, + ) +``` + +The query must include `.orderBy()`. `fetchNextPage()` resolves when the page request settles; failures appear in `error`. `getNextPageParam` is rejected. + +## Preact-Specific Patterns + +### Query identity + +The hook derives identity from the query IR on every render. Captured values in structured expressions recompile the query when they change, so there is no dependency array: + +```tsx +function Filtered({ minPriority }: { minPriority: number }) { + const { data } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => gt(todo.priority, minPriority)), + ) + return
{data.length}
+} +``` + +While a hook is mounted, a source Collection ID must keep naming the same Collection object. A different object under a previously read ID throws `SourceCollectionIdReuseError`. + +## Common Mistakes + +### CRITICAL Using opaque query logic without queryKey + +Wrong: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .fn.where(({ todo }) => todo.text.includes(search)), +) +``` + +Correct: + +```tsx +const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q + .from({ todo: todoCollection }) + .fn.where(({ todo }) => todo.text.includes(search)), +}) +``` + +Functional variants (`fn.where`, `fn.select`, `fn.having`) have no stable IR identity, so without `queryKey` the hook throws `UnhashableQueryIRError`. Put every value the function captures into the key. + +### HIGH Using === instead of eq() in .where() + +`.where(({ todo }) => todo.completed === false)` compares a query reference, not a value. Use `eq(todo.completed, false)` or `not(todo.completed)`. + +### HIGH Reading data of a disabled query without narrowing + +A callback that can return `undefined` makes `data` possibly `undefined`. Check `isEnabled` or use `data?.`. diff --git a/packages/preact-db/src/index.ts b/packages/preact-db/src/index.ts new file mode 100644 index 0000000000..e687284aac --- /dev/null +++ b/packages/preact-db/src/index.ts @@ -0,0 +1,22 @@ +// Re-export all public APIs +export { useLiveQuery } from './useLiveQuery' +export type { + ConditionalUseLiveQueryConfig, + UseConditionalLiveQueryResult, + UseLiveQueryConfig, + UseLiveQueryResult, + UseLiveQueryStatus, +} from './useLiveQuery' +export { useLiveInfiniteQuery } from './useLiveInfiniteQuery' +export type { + UseLiveInfiniteQueryConfig, + UseLiveInfiniteQueryReturn, + UseLiveInfiniteQueryReturnWithCollection, +} from './useLiveInfiniteQuery' + +// Re-export everything from @tanstack/db +export * from '@tanstack/db' + +// Re-export some stuff explicitly to ensure the type & value is exported +export type { Collection } from '@tanstack/db' +export { createTransaction } from '@tanstack/db' diff --git a/packages/preact-db/src/useLiveInfiniteQuery.ts b/packages/preact-db/src/useLiveInfiniteQuery.ts new file mode 100644 index 0000000000..c73e9b93f5 --- /dev/null +++ b/packages/preact-db/src/useLiveInfiniteQuery.ts @@ -0,0 +1,183 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks' +import { + bindLiveQueryWindow, + createLiveQuerySourceIdClaims, + fetchNextLiveQueryWindowPage, + getLiveQueryHash, + identifyLiveQuery, + prepareLiveQueryValue, + readLiveQueryWindowPageShape, +} from '@tanstack/db' +import type { + Collection, + CollectionStatus, + Context, + InferResultType, + InitialQueryBuilder, + LiveQueryKey, + LiveQuerySourceIdClaims, + LiveQueryWindowBinding, + NonSingleResult, + QueryBuilder, +} from '@tanstack/db' +import type { UseLiveQueryResult } from './useLiveQuery' + +// Keep the generic parameter for typed config wrappers. +export type UseLiveInfiniteQueryConfig<_TContext extends Context> = { + /** + * Explicit identity for a query whose IR cannot derive one, such as a query + * with functional variants. Structured queries should omit it. + */ + queryKey?: LiveQueryKey + pageSize?: number + /** First result-page label, not a server cursor or remote offset. */ + initialPageParam?: number +} + +interface InfiniteQueryPagination { + pages: Array> + pageParams: Array + fetchNextPage: () => Promise + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown +} + +export type UseLiveInfiniteQueryReturn = + UseLiveQueryResult< + InferResultType[number], + string | number, + InferResultType, + Collection[number], string | number, {}> + > & + InfiniteQueryPagination[number]> + +export type UseLiveInfiniteQueryReturnWithCollection< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +> = UseLiveQueryResult< + TResult, + TKey, + Array, + Collection & NonSingleResult +> & + InfiniteQueryPagination + +/** What one hook instance renders until its input or page shape changes. */ +/** + * Page through an ordered live query, growing one live-query Collection's + * window with `utils.setWindow()` instead of recreating it per page. + * + * The query must include `.orderBy()`. Its identity follows `useLiveQuery`: + * derived from the query IR, or named by `queryKey`. A query whose IR cannot be + * hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied. + * + * @example + * const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + * (q) => + * q + * .from({ posts: postsCollection }) + * .orderBy(({ posts }) => posts.createdAt, `desc`), + * { pageSize: 20 }, + * ) + */ +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & NonSingleResult, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturnWithCollection + +export function useLiveInfiniteQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn + +export function useLiveInfiniteQuery( + queryFnOrCollection: unknown, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn { + const pageShape = readLiveQueryWindowPageShape(config) + const claimsRef = useRef(null) + const bindingRef = useRef(null) + + // A query input's identity is a hash, so a structurally equal query keeps + // its loaded pages and a changed one compiles again. + let preparedValue: unknown + let identity: string | undefined + if (typeof queryFnOrCollection === `function`) { + if (config.queryKey?.length) { + identity = getLiveQueryHash(undefined, config.queryKey) + } else { + preparedValue = prepareLiveQueryValue(queryFnOrCollection, undefined) + identity = identifyLiveQuery( + preparedValue, + { + claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()), + client: undefined, + bindingName: `useLiveInfiniteQuery`, + }, + getLiveQueryHash, + ) + } + } + + const binding = (bindingRef.current = bindLiveQueryWindow( + bindingRef.current, + { + ...pageShape, + // Compile what this render prepared, so the callback runs once. + input: + preparedValue === undefined ? queryFnOrCollection : () => preparedValue, + dependencies: identity === undefined ? undefined : [identity], + recompile: `identity`, + }, + )) + + const { controller } = binding + const snapshot = controller.getSnapshot() + const [, setRevision] = useState(0) + + useLayoutEffect(() => { + const rerender = () => setRevision((revision) => revision + 1) + controller.subscribe(rerender) + // The query can publish between this render and its subscription. + if (controller.getSnapshot() !== snapshot) rerender() + if (binding.warning) console.warn(binding.warning) + // Each binding owns its controller, so disposal also detaches this + // subscription and releases its window lease. + return () => controller.dispose() + }, [controller]) + + const fetchNextPage = useCallback( + () => fetchNextLiveQueryWindowPage(controller), + [controller], + ) + + return { + data: snapshot.data, + get state() { + return snapshot.state + }, + status: snapshot.status as CollectionStatus, + isLoading: snapshot.isLoading, + isReady: snapshot.isReady, + persistedStatus: snapshot.persistedStatus, + isPersistedReady: snapshot.isPersistedReady, + persistedError: snapshot.persistedError, + isIdle: snapshot.isIdle, + isError: snapshot.isError, + isCleanedUp: snapshot.isCleanedUp, + collection: snapshot.collection, + isEnabled: true, + pages: snapshot.pages, + pageParams: snapshot.pageParams, + fetchNextPage, + hasNextPage: snapshot.hasNextPage, + isFetchingNextPage: snapshot.isFetchingNextPage, + error: snapshot.error, + } as UseLiveInfiniteQueryReturn +} diff --git a/packages/preact-db/src/useLiveQuery.ts b/packages/preact-db/src/useLiveQuery.ts new file mode 100644 index 0000000000..a2936ba844 --- /dev/null +++ b/packages/preact-db/src/useLiveQuery.ts @@ -0,0 +1,292 @@ +import { useLayoutEffect, useRef, useState } from 'preact/hooks' +import { + createLiveQueryObserver, + createLiveQuerySourceIdClaims, + getLiveQueryHash, + getLiveQueryKey, + identifyLiveQuery, + isCollection, + prepareLiveQueryValue, + resolveLiveQueryValue, +} from '@tanstack/db' +import type { + Collection, + CollectionStatus, + Context, + GetResult, + InferResultType, + InitialQueryBuilder, + LiveQueryCollectionConfig, + LiveQueryKey, + LiveQueryObserver, + LiveQueryPersistedStatus, + LiveQuerySourceIdClaims, + NonSingleResult, + QueryBuilder, + SingleResult, +} from '@tanstack/db' + +// A live query this hook creates from a callback or config retires 1 ms after +// its last subscriber leaves. A supplied Collection, or a config with an +// explicit `gcTime`, keeps the caller's retention policy. +const DEFAULT_GC_TIME_MS = 1 + +export type UseLiveQueryStatus = CollectionStatus | `disabled` + +type ConfiguredQueryBuilder = Extract< + LiveQueryCollectionConfig[`query`], + QueryBuilder +> + +type UseLiveQueryConfigOptions = Omit< + LiveQueryCollectionConfig, + `query` +> & { + /** + * Explicit identity for a query whose IR cannot derive one, such as a query + * with functional variants. Structured queries should omit it. + */ + queryKey?: LiveQueryKey +} + +export type UseLiveQueryConfig = + UseLiveQueryConfigOptions & + Pick, `query`> + +export type ConditionalUseLiveQueryConfig = + UseLiveQueryConfigOptions & { + query: + | ConfiguredQueryBuilder + | (( + q: InitialQueryBuilder, + ) => ConfiguredQueryBuilder | undefined | null) + } + +interface LiveQueryResultFlags { + isLoading: boolean + isReady: boolean + persistedStatus: LiveQueryPersistedStatus + isPersistedReady: boolean + persistedError: unknown | undefined + isIdle: boolean + isError: boolean + isCleanedUp: boolean +} + +/** The result of a live query that is always enabled. */ +export interface UseLiveQueryResult< + TResult extends object, + TKey extends string | number, + TData, + TCollection, +> extends LiveQueryResultFlags { + state: ReadonlyMap + data: TData + collection: TCollection + status: CollectionStatus + isEnabled: true +} + +/** The result of a live query whose callback can disable it. */ +export interface UseConditionalLiveQueryResult< + TResult extends object, + TKey extends string | number, + TData, + TCollection, +> extends LiveQueryResultFlags { + state: ReadonlyMap | undefined + data: TData | undefined + collection: TCollection | undefined + status: UseLiveQueryStatus + isEnabled: boolean +} + +type QueryResult = UseLiveQueryResult< + GetResult, + string | number, + InferResultType, + Collection, string | number, {}> +> + +type ConditionalQueryResult = + UseConditionalLiveQueryResult< + GetResult, + string | number, + InferResultType, + Collection, string | number, {}> + > + +/** + * Subscribe a Preact component to a live query. + * + * The hook derives the query's identity from its structured IR on each render, + * so values captured by the callback recompile the query when they change. A + * query whose IR cannot be hashed, such as one using `fn.where`, throws + * `UnhashableQueryIRError` unless its config supplies `queryKey`. While the + * hook is mounted, each Collection ID its queries read must keep naming one + * source object, or it throws `SourceCollectionIdReuseError`. + * Return `undefined` or `null` from the callback to disable the query. + * + * @example + * const { data, isLoading } = useLiveQuery((q) => + * q + * .from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.completed, false)), + * ) + * + * @example + * // Disable the query until its input exists + * const { data, isEnabled } = useLiveQuery((q) => + * userId + * ? q + * .from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.userId, userId)) + * : undefined, + * ) + */ +export function useLiveQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, +): QueryResult + +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder, + ) => QueryBuilder | undefined | null, +): ConditionalQueryResult + +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder, + ) => LiveQueryCollectionConfig | undefined | null, +): ConditionalQueryResult + +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + queryFn: ( + q: InitialQueryBuilder, + ) => Collection | undefined | null, +): UseConditionalLiveQueryResult< + TResult, + TKey, + Array, + Collection +> + +/** + * Subscribe to a live query described by a config object. + * + * @example + * const { data } = useLiveQuery({ + * query: (q) => q.from({ todos: todosCollection }), + * gcTime: 60_000, + * }) + */ +export function useLiveQuery( + config: UseLiveQueryConfig, +): QueryResult + +export function useLiveQuery( + config: ConditionalUseLiveQueryConfig, +): ConditionalQueryResult + +/** + * Subscribe to an existing live-query Collection. The hook starts its sync and + * leaves its retention policy to the caller. + * + * @example + * const activeTodos = createLiveQueryCollection((q) => + * q.from({ todos: todosCollection }).where(({ todos }) => todos.active), + * ) + * const { data, collection } = useLiveQuery(activeTodos) + */ +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & NonSingleResult, +): UseLiveQueryResult< + TResult, + TKey, + Array, + Collection +> + +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & SingleResult, +): UseLiveQueryResult< + TResult, + TKey, + TResult | undefined, + Collection & SingleResult +> + +export function useLiveQuery( + input: unknown, +): UseConditionalLiveQueryResult< + object, + string | number, + unknown, + Collection +> { + const claimsRef = useRef(null) + const preparedValue = prepareLiveQueryValue(input, undefined) + const queryKey = getLiveQueryKey(input) + // A supplied Collection is not compiled, so its object is its identity. + const identity = isCollection(input) + ? input + : queryKey + ? getLiveQueryHash(preparedValue, queryKey) + : identifyLiveQuery( + preparedValue, + { + claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()), + client: undefined, + bindingName: `useLiveQuery`, + }, + getLiveQueryHash, + ) + + const bindingRef = useRef(null) + let binding = bindingRef.current + if (binding?.identity !== identity) { + binding = bindingRef.current = { + identity, + // Wholesale mode publishes nothing during subscribe; the hook re-reads + // the observer's snapshot on each notification. + observer: createLiveQueryObserver( + resolveLiveQueryValue(preparedValue, { gcTime: DEFAULT_GC_TIME_MS }), + { mode: `wholesale` }, + ), + } + } + + const { observer } = binding + const snapshot = observer.getSnapshot() + const [, setRevision] = useState(0) + + useLayoutEffect(() => { + const rerender = () => setRevision((revision) => revision + 1) + observer.subscribe(rerender) + // The query can publish between this render and its subscription. + if (observer.getSnapshot() !== snapshot) rerender() + // Each binding owns its observer, so disposal also detaches this + // subscription and releases the live-query Collection. + return () => observer.dispose() + }, [observer]) + + return snapshot +} + +/** The live query one hook instance reads until its identity changes. */ +interface LiveQueryBinding { + identity: unknown + observer: LiveQueryObserver +} diff --git a/packages/preact-db/tests/conformance.test.ts b/packages/preact-db/tests/conformance.test.ts new file mode 100644 index 0000000000..1641aec74b --- /dev/null +++ b/packages/preact-db/tests/conformance.test.ts @@ -0,0 +1,239 @@ +/** + * Preact driver for the shared live-query conformance suite. + * + * Everything realm-sensitive — collection creation and query operators — is + * imported here (Preact package's `@tanstack/db`) and handed to the shared + * scenarios, so instances match what this package's `useLiveQuery` expects. + * + * Observation cut: `render-probe.ts` records the hook result at each Preact + * commit. `flush` is one `act` around a macrotask, so framework updates and + * core sync settle before the suite reads the last committed result. A + * rejected query throws from the mount or from `setParam`'s rerender. + * + * `mountControllable` captures its parameter in the query callback instead of + * passing a dependency list. The hook derives identity from the query IR, so + * this exercises recompilation through the public API that Preact offers. + * + * All registered laws must pass; the driver has no whole-test waivers. + */ +import { act } from 'preact/test-utils' +import { + coalesce, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, + sum, +} from '@tanstack/db' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import { runSuite } from '../../db/tests/conformance/suite' +import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { expectResultSurface } from '../../db/tests/conformance/result-laws' +import { renderHook, settle } from './render-probe' +import type { MountedHook } from './render-probe' +import type { + ConformanceResult, + ControllableHandle, + DeferredSourceHandle, + LiveQueryDriver, + LiveQueryHandle, + QueryBuild, + SourceHandle, +} from '../../db/tests/conformance/contract' + +let sourceSeq = 0 + +function writer(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: collectionId ?? `conformance-preact-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(collection) + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + } +} + +function makeDeferredSource< + T extends { id: string }, +>(): DeferredSourceHandle { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-preact-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + // Start sync so the sync fn binds utils and the collection sits in `loading` + // (NoInitialState never calls markReady on its own). + collection.startSyncImmediate() + const write = writer(collection) + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + emit: (rows) => { + collection.utils.begin() + rows.forEach((value) => collection.utils.write({ type: `insert`, value })) + collection.utils.commit() + }, + markReady: () => collection.utils.markReady(), + } +} + +function makePrecreated( + build: QueryBuild, + opts?: { startSync?: boolean; gcTime?: number }, +) { + const collection = createLiveQueryCollection({ + query: build as any, + startSync: opts?.startSync ?? true, + gcTime: opts?.gcTime, + }) + 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-preact-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 makeHandle(hook: MountedHook): LiveQueryHandle { + return { + collection: () => hook.result.current?.collection ?? null, + current(): ConformanceResult { + const r: any = hook.result.current + return expectResultSurface({ + data: r?.data, + state: r?.state, + status: r?.status, + isReady: r?.isReady, + persistedStatus: r?.persistedStatus, + isPersistedReady: r?.isPersistedReady, + persistedError: r?.persistedError, + isError: r?.isError, + // Read the hook's real `isEnabled` field so the suite catches a broken + // one (deriving from status would mask it). + isEnabled: r?.isEnabled, + }) + }, + flush: settle, + async apply(fn: () => void) { + await act(async () => { + fn() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + }, + unmount() { + hook.unmount() + }, + } +} + +function mount(build: QueryBuild) { + return makeHandle(renderHook(() => useLiveQuery(build as any))) +} + +function mountCollection(collection: any) { + return makeHandle(renderHook(() => useLiveQuery(collection))) +} + +function mountConfig( + build: QueryBuild, + opts?: { gcTime?: number; queryKey?: ReadonlyArray }, +) { + return makeHandle( + renderHook(() => useLiveQuery({ query: build as any, ...opts })), + ) +} + +function mountDisabled() { + // Preact's disabled convention: the query callback returns null. + return makeHandle(renderHook(() => useLiveQuery(() => null as any))) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + const hook = renderHook( + ({ param }: { param: P }) => useLiveQuery((q: any) => build(q, param)), + { initialProps: { param: initial } }, + ) + const handle = makeHandle(hook) + return { + ...handle, + async setParam(param: P) { + hook.rerender({ param }) + await settle() + }, + } +} + +const preactDriver: LiveQueryDriver = { + name: `preact`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, + disabledRepresentation: `absent`, + ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, + makeSource, + makeDeferredSource, + makeLaggingSource: () => + makeLaggingSource( + createCollection, + `conformance-preact-lagging-${sourceSeq++}`, + ), + makePrecreated, + makeErrorSource, + mount, + mountControllable, + mountCollection, + mountConfig, + mountDisabled, + knownGaps: [], + features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, +} + +runSuite(preactDriver) diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts new file mode 100644 index 0000000000..65399cce8d --- /dev/null +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -0,0 +1,254 @@ +/** + * Preact driver for the shared infinite-query conformance suite. + * + * The probe in `render-probe.ts`, `act`, and the hook result are the only + * Preact-specific layer. Sources and query operators come from this package's + * module realm. The shared suite owns the page model and histories; this + * driver defines when Preact has committed enough work for those public + * observations to be read. + * + * Publication cut: Preact publishes at each commit, and the probe records + * every committed value in a layout effect. Preact has no render that skips + * its commit, so the first observation is the first paint. + * + * Controllable mounts capture their parameter in the query callback. The hook + * derives identity from the query IR, so a changed parameter recompiles and a + * structurally equal one keeps the loaded pages. `setParamSync` rerenders + * inside `act`, which commits and attaches the replacement subscription before + * it returns. + */ +import { act } from 'preact/test-utils' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + createLiveQueryWindowController, + gt, +} from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' +import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { renderHook, settle } from './render-probe' +import type { MountedHook } from './render-probe' +import type { + InfiniteQueryConfig, + InfiniteQueryDriver, + InfiniteQueryHandle, + InfiniteQueryObservation, +} from '../../db/tests/conformance/infinite-contract' +import type { QueryBuild } from '../../db/tests/conformance/contract' + +let sourceSequence = 0 + +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +) { + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: collectionId ?? `infinite-conformance-preact-${sourceSequence++}`, + getKey: (row) => row.id, + initialData: [...initialData], + }), + ) + const write = (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } + return { + collection, + insert: (row: T) => write(`insert`, row), + update: (row: T) => write(`update`, row), + remove: (row: T) => write(`delete`, row), + } +} + +function makePrecreated(build: QueryBuild) { + return { + collection: createLiveQueryCollection({ query: build as any }), + } +} + +type Observations = Array + +function observe(result: any): InfiniteQueryObservation { + return { + status: result.status, + ids: result.data.map((row: { id: string }) => row.id), + pages: result.pages.map((page: Array<{ id: string }>) => + page.map((row) => row.id), + ), + hasNextPage: result.hasNextPage, + } +} + +/** Mount and record every committed value as an observation. */ +function mountRecorded

( + useResult: (props: P) => unknown, + initialProps?: P, +): { hook: MountedHook

; log: Observations } { + const log: Observations = [] + const hook = renderHook(useResult, { + initialProps, + onCommit: (result) => log.push(observe(result)), + }) + return { hook, log } +} + +function makeHandle( + hook: MountedHook, + log: Observations, +): InfiniteQueryHandle { + return { + current() { + const result = hook.result.current + return { + data: result.data, + pages: result.pages, + pageParams: result.pageParams, + hasNextPage: result.hasNextPage, + isFetchingNextPage: result.isFetchingNextPage, + error: result.error, + status: result.status, + collection: result.collection, + } + }, + observations: () => log, + fetchNextPage() { + let request!: Promise + act(() => { + request = hook.result.current.fetchNextPage() + }) + return request + }, + flush: settle, + async apply(fn) { + await act(async () => { + fn() + await Promise.resolve() + }) + }, + unmount() { + hook.unmount() + }, + } +} + +function rerenderSync

(hook: MountedHook

, props: P): void { + // The probe rerenders inside `act`, so the commit and the replacement + // subscription complete before this returns. + hook.rerender(props) +} + +function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { + const { hook, log } = mountRecorded(() => + useLiveInfiniteQuery(build as any, config as any), + ) + return makeHandle(hook, log) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, +) { + const { hook, log } = mountRecorded( + ({ param }: { param: P }) => + useLiveInfiniteQuery((q: any) => build(q, param), config as any), + { param: initial }, + ) + return { + ...makeHandle(hook, log), + setParamSync: (param: P) => rerenderSync(hook, { param }), + } +} + +function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { + const { hook, log } = mountRecorded(() => + useLiveInfiniteQuery(collection, config as any), + ) + return makeHandle(hook, log) +} + +function mountCollectionControllable( + initial: any, + config: InfiniteQueryConfig = {}, +) { + const { hook, log } = mountRecorded( + ({ collection }: { collection: any }) => + useLiveInfiniteQuery(collection, config as any), + { collection: initial }, + ) + return { + ...makeHandle(hook, log), + replaceCollectionSync: (collection: any) => + rerenderSync(hook, { collection }), + } +} + +function mountConfigControllable( + build: QueryBuild, + initial: InfiniteQueryConfig, +) { + const { hook, log } = mountRecorded( + ({ config }: { config: InfiniteQueryConfig }) => + useLiveInfiniteQuery(build as any, config as any), + { config: initial }, + ) + return { + ...makeHandle(hook, log), + setConfigSync: (config: InfiniteQueryConfig) => + rerenderSync(hook, { config }), + } +} + +function mountInputControllable( + collection: any, + build: QueryBuild, + config: InfiniteQueryConfig = {}, +) { + type Kind = `collection` | `query` + const { hook, log } = mountRecorded( + ({ kind }: { kind: Kind }) => + useLiveInfiniteQuery( + kind === `collection` ? collection : build, + config as any, + ), + { kind: `collection` as Kind }, + ) + return { + ...makeHandle(hook, log), + setInputKindSync: (kind: Kind) => rerenderSync(hook, { kind }), + } +} + +const preactInfiniteDriver: InfiniteQueryDriver = { + name: `preact`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, + gt, + makeSource, + makeOnDemandSource: (data, delay) => + makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), + makeLaggingOnDemandSource: (data) => + makeInfiniteOnDemandSource( + { createCollection, BTreeIndex }, + data, + undefined, + `lagging`, + ), + makePrecreated, + makeWindowController: createLiveQueryWindowController, + mount, + mountControllable, + mountCollection, + mountCollectionControllable, + mountConfigControllable, + mountInputControllable, + knownGaps: [], +} + +runInfiniteQuerySuite(preactInfiniteDriver) diff --git a/packages/preact-db/tests/render-probe.ts b/packages/preact-db/tests/render-probe.ts new file mode 100644 index 0000000000..99705a1939 --- /dev/null +++ b/packages/preact-db/tests/render-probe.ts @@ -0,0 +1,95 @@ +/** + * Commit-time observation for Preact drivers. + * + * The probe component records the hook result in a layout effect, which + * Preact runs synchronously when it commits a render. A source write outside + * `act` still schedules a real Preact render, and that render commits on the + * next microtask. Passive effects are different: Preact runs them after + * paint, and `act` drains only the ones scheduled inside it, so a `useEffect` + * recorder (as in `@testing-library/preact`'s `renderHook`) can miss a + * committed render. + * + * Rejection: the probe sits under an error boundary, as a real app's hook + * would. A render that throws is caught there, the boundary unmounts the + * failed hook (releasing its subscription), and the probe rethrows the + * captured error from the mount or rerender that caused it, and from every + * later read: the failed hook is no longer mounted. Without the + * boundary, Preact keeps the failed component mounted, and a later + * notification re-renders it outside `act`. + */ +import { Component, h, render } from 'preact' +import { useLayoutEffect } from 'preact/hooks' +import { act } from 'preact/test-utils' + +class RejectionBoundary extends Component<{ + onError: (error: unknown) => void + children?: any +}> { + state = { failed: false } + componentDidCatch(error: unknown) { + this.props.onError(error) + this.setState({ failed: true }) + } + render() { + return this.state.failed ? null : this.props.children + } +} + +export interface MountedHook

{ + result: { current: any } + rerender: (props: P) => void + unmount: () => void +} + +/** Mount one hook in a probe component and record each committed result. */ +export function renderHook

( + useResult: (props: P) => unknown, + options?: { initialProps?: P; onCommit?: (result: any) => void }, +): MountedHook

{ + const container = document.createElement(`div`) + let committed: unknown + let rejection: { error: unknown } | undefined + const result = { + get current(): any { + if (rejection) throw rejection.error + return committed + }, + } + function Probe({ props }: { props: P }) { + const value = useResult(props) + useLayoutEffect(() => { + committed = value + options?.onCommit?.(value) + }) + return null + } + const unmount = () => act(() => render(null, container)) + /** Render inside `act`, then rethrow a rejection the boundary caught. */ + const commit = (props: P) => { + act(() => { + render( + h( + RejectionBoundary, + { onError: (error) => (rejection ??= { error }) }, + h(Probe, { props }), + ), + container, + ) + }) + if (rejection) throw rejection.error + } + // A rejected first render must not leave its container mounted. + try { + commit(options?.initialProps as P) + } catch (error) { + unmount() + throw error + } + return { result, rerender: commit, unmount } +} + +export async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} diff --git a/packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts b/packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts new file mode 100644 index 0000000000..d57368ec60 --- /dev/null +++ b/packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts @@ -0,0 +1,59 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import type { OutputWithVirtual } from '../../db/tests/utils' + +type Post = { id: string; title: string; rank: number } + +const posts = createCollection( + mockSyncCollectionOptions({ + id: `type-posts`, + getKey: (post) => post.id, + initialData: [], + }), +) + +describe(`useLiveInfiniteQuery types`, () => { + it(`types pages of a query callback`, () => { + const result = useLiveInfiniteQuery( + (q) => + q + .from({ post: posts }) + .orderBy(({ post }) => post.rank, `desc`) + .select(({ post }) => ({ id: post.id, title: post.title })), + { pageSize: 10 }, + ) + expectTypeOf(result.data).toMatchTypeOf< + Array> + >() + expectTypeOf(result.pages).toMatchTypeOf< + Array>> + >() + expectTypeOf(result.pageParams).toEqualTypeOf>() + expectTypeOf(result.fetchNextPage).toEqualTypeOf<() => Promise>() + expectTypeOf(result.hasNextPage).toEqualTypeOf() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) + + it(`types pages of a supplied live-query Collection`, () => { + const ranked = createLiveQueryCollection((q) => + q.from({ post: posts }).orderBy(({ post }) => post.rank), + ) + const result = useLiveInfiniteQuery(ranked, { pageSize: 5 }) + expectTypeOf(result.pages).toMatchTypeOf< + Array>> + >() + }) + + it(`accepts queryKey for functional variants`, () => { + useLiveInfiniteQuery( + (q) => + q + .from({ post: posts }) + .fn.where(({ post }) => post.rank > 0) + .orderBy(({ post }) => post.rank), + { pageSize: 5, queryKey: [`ranked`] }, + ) + }) +}) diff --git a/packages/preact-db/tests/useLiveQuery.test-d.ts b/packages/preact-db/tests/useLiveQuery.test-d.ts new file mode 100644 index 0000000000..a283992ef7 --- /dev/null +++ b/packages/preact-db/tests/useLiveQuery.test-d.ts @@ -0,0 +1,81 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import type { CollectionStatus } from '@tanstack/db' +import type { OutputWithVirtual } from '../../db/tests/utils' +import type { UseLiveQueryStatus } from '../src/index' + +type Person = { id: string; name: string; age: number } + +const people = createCollection( + mockSyncCollectionOptions({ + id: `type-people`, + getKey: (person) => person.id, + initialData: [], + }), +) + +describe(`useLiveQuery types`, () => { + it(`types an always-enabled query callback`, () => { + const result = useLiveQuery((q) => + q + .from({ person: people }) + .select(({ person }) => ({ id: person.id, name: person.name })), + ) + expectTypeOf(result.data).toMatchTypeOf< + Array> + >() + expectTypeOf(result.status).toEqualTypeOf() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) + + it(`types a callback that can disable the query`, () => { + const enabled = null as unknown as boolean + const result = useLiveQuery((q) => + enabled ? q.from({ person: people }) : undefined, + ) + expectTypeOf(result.data).toMatchTypeOf< + Array> | undefined + >() + expectTypeOf(result.state).toMatchTypeOf< + ReadonlyMap> | undefined + >() + // A disabled query has no data, so the result must be narrowed first. + // @ts-expect-error data can be undefined + result.data.map((person) => person.id) + expectTypeOf(result.status).toEqualTypeOf() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) + + it(`types findOne as one row or undefined`, () => { + const result = useLiveQuery((q) => + q + .from({ person: people }) + .where(({ person }) => eq(person.id, `1`)) + .findOne(), + ) + expectTypeOf(result.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() + }) + + it(`types the config-object form`, () => { + const result = useLiveQuery({ + query: (q) => q.from({ person: people }), + gcTime: 1000, + }) + expectTypeOf(result.data).toMatchTypeOf>>() + }) + + it(`types a supplied live-query Collection`, () => { + const adults = createLiveQueryCollection((q) => + q.from({ person: people }).select(({ person }) => ({ id: person.id })), + ) + const result = useLiveQuery(adults) + expectTypeOf(result.data).toMatchTypeOf< + Array> + >() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) +}) diff --git a/packages/preact-db/tests/useLiveQuery.test.ts b/packages/preact-db/tests/useLiveQuery.test.ts new file mode 100644 index 0000000000..6eba766e8d --- /dev/null +++ b/packages/preact-db/tests/useLiveQuery.test.ts @@ -0,0 +1,142 @@ +/** + * Preact-specific binding witnesses for `useLiveQuery`. + * + * The shared conformance suite owns the cross-framework result and identity + * laws. These witnesses cover Preact binding obligations its histories cannot + * reach: + * + * - A publication between render and subscription is not lost. A sibling's + * layout effect can write to the source after this hook rendered but before + * it subscribed; the committed result must include that write. + * - Identity comes from the query IR or from `queryKey`, as in React. A + * changed key recompiles the query, and an equal key keeps its live-query + * Collection. A structurally equal rerender keeps it too. + * + * Observation: each component records the rows it rendered. Preact commits a + * render synchronously, so the last render is the committed result. + */ +import { Fragment, h, render } from 'preact' +import { useLayoutEffect } from 'preact/hooks' +import { act } from 'preact/test-utils' +import { createCollection, eq } from '@tanstack/db' +import { afterEach, describe, expect, it } from 'vitest' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import type { ComponentChild } from 'preact' + +type Row = { id: string; team: string } + +const containers: Array = [] + +function mountRoot(vnode: ComponentChild): HTMLElement { + const container = document.createElement(`div`) + containers.push(container) + act(() => render(vnode, container)) + return container +} + +afterEach(() => { + for (const container of containers.splice(0)) { + act(() => render(null, container)) + } +}) + +function makeSource(id: string, rows: Array) { + return createCollection( + mockSyncCollectionOptions({ + id, + getKey: (row) => row.id, + initialData: rows, + }), + ) +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe(`useLiveQuery subscription`, () => { + it(`publishes a write made between render and subscription`, async () => { + const source = makeSource(`late-write`, [{ id: `a`, team: `x` }]) + let rendered: Array = [] + // Preact renders the whole tree before it runs layout effects, and runs + // sibling layout effects in render order. Placed first, this write lands + // after Reader rendered and before Reader's subscription effect runs. + function Writer() { + useLayoutEffect(() => { + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `b`, team: `x` } }) + source.utils.commit() + }, []) + return null + } + function Reader() { + const { data } = useLiveQuery((q) => q.from({ row: source })) + rendered = data.map((row) => row.id) + return null + } + + mountRoot(h(Fragment, null, h(Writer, null), h(Reader, null))) + await settle() + expect(rendered).toEqual([`a`, `b`]) + }) +}) + +describe(`useLiveQuery identity`, () => { + it(`keeps a structurally equal query across rerenders`, async () => { + const source = makeSource(`structural`, [ + { id: `a`, team: `x` }, + { id: `b`, team: `y` }, + ]) + let collection: unknown + function Reader({ team }: { team: string }) { + const result = useLiveQuery((q) => + q.from({ row: source }).where(({ row }) => eq(row.team, team)), + ) + collection = result.collection + return null + } + const container = mountRoot(h(Reader, { team: `x` })) + await settle() + const first = collection + act(() => render(h(Reader, { team: `x` }), container)) + expect(collection).toBe(first) + act(() => render(h(Reader, { team: `y` }), container)) + expect(collection).not.toBe(first) + }) + + it(`recompiles a functional query when its queryKey changes`, async () => { + const source = makeSource(`functional-key`, [ + { id: `a`, team: `x` }, + { id: `b`, team: `y` }, + ]) + let rendered: Array = [] + let collection: unknown + function Reader({ team }: { team: string }) { + const result = useLiveQuery({ + queryKey: [`team`, team], + query: (q) => + q.from({ row: source }).fn.where(({ row }) => row.team === team), + }) + rendered = result.data.map((row) => row.id) + collection = result.collection + return null + } + + const container = mountRoot(h(Reader, { team: `x` })) + await settle() + expect(rendered).toEqual([`a`]) + const first = collection + + act(() => render(h(Reader, { team: `x` }), container)) + await settle() + expect(collection).toBe(first) + + act(() => render(h(Reader, { team: `y` }), container)) + await settle() + expect(rendered).toEqual([`b`]) + expect(collection).not.toBe(first) + }) +}) diff --git a/packages/preact-db/tsconfig.docs.json b/packages/preact-db/tsconfig.docs.json new file mode 100644 index 0000000000..5a73feb023 --- /dev/null +++ b/packages/preact-db/tsconfig.docs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"] + } + }, + "include": ["src"] +} diff --git a/packages/preact-db/tsconfig.json b/packages/preact-db/tsconfig.json new file mode 100644 index 0000000000..2fa57ac0a8 --- /dev/null +++ b/packages/preact-db/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "paths": { + "@tanstack/store": ["../store/src"], + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"] + } + }, + "include": ["src/**/*", "tests", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/preact-db/vite.config.ts b/packages/preact-db/vite.config.ts new file mode 100644 index 0000000000..82b9f620b8 --- /dev/null +++ b/packages/preact-db/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: `./tests`, + environment: `jsdom`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + // Preact 11 publishes ESM only, so a CommonJS build could not require it. + cjs: false, + }), +) diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 562d9b3d36..1d05c237e0 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -294,9 +294,9 @@ const { data } = useLiveQuery({ }) ``` -Before 1.0, opaque IR warns and keeps legacy mount-stable identity. Slow or -repeated derived identity work also warns once. Both point to the same -`queryKey` escape hatch; unhashable IR without a key will throw in 1.0. +Unhashable IR without `queryKey` throws `UnhashableQueryIRError`, even with a +dependency array. Slow or repeated derived identity work warns once and points +to the same `queryKey` escape hatch. ### Suspense + Error Boundary diff --git a/packages/react-db/src/source-id-bindings.ts b/packages/react-db/src/source-id-bindings.ts index c0fcb29acd..ee9b3678ec 100644 --- a/packages/react-db/src/source-id-bindings.ts +++ b/packages/react-db/src/source-id-bindings.ts @@ -1,75 +1,29 @@ import { getPreparedLiveQuerySources } from '@tanstack/db' import type { DbClient } from '@tanstack/db' -export { resumeDeferredLiveQueryCollections as resumeSyncStarts } from '@tanstack/db' - -const sourceObjectTokens = new WeakMap() -let nextSourceObjectToken = 0 - -export function getSourceObjectToken(source: object): number { - let id = sourceObjectTokens.get(source) - if (id === undefined) { - id = ++nextSourceObjectToken - sourceObjectTokens.set(source, id) +/** + * Reports a descriptor source that no DbClient bound, naming the provider a + * React tree needs. Returns `undefined` when a client can bind descriptors. + */ +export function getUnboundDescriptorReporter( + client: DbClient | undefined, + hookName: string, +): ((alias: string) => never) | undefined { + if (client) return undefined + return (alias) => { + throw new Error( + `[${hookName}] Collection descriptor "${alias}" requires a DbClient when the query is consumed. Wrap this component in or use a concrete Collection.`, + ) } - return id } export function getPreparedSources( preparedValue: unknown, - client?: DbClient, - hookName?: string, + client: DbClient | undefined, + hookName: string, ): Array<{ id: string }> { return getPreparedLiveQuerySources( preparedValue, - !client && hookName - ? (alias) => { - throw new Error( - `[${hookName}] Collection descriptor "${alias}" requires a DbClient when the query is consumed. Wrap this component in or use a concrete Collection.`, - ) - } - : undefined, + getUnboundDescriptorReporter(client, hookName), ) } - -/** The source object each Collection ID named while one hook is mounted. */ -export type SourceIdBindings = { - unscoped: Map - byClient: WeakMap> -} - -export function createSourceIdBindings(): SourceIdBindings { - return { unscoped: new Map(), byClient: new WeakMap() } -} - -/** - * Derived query identity names each source by its Collection ID. While a hook - * is mounted, an ID must keep naming one source object in each client scope, - * or the hook could reuse a live query that reads the old source. Claims the - * prepared value's sources, or calls `release` and throws on a collision so a - * rejected render keeps no sync-start deferral. - */ -export function claimSourceIds( - bindings: SourceIdBindings, - preparedValue: unknown, - client: DbClient | undefined, - hookName: string, - release: () => void, -): void { - const prior = client ? bindings.byClient.get(client) : bindings.unscoped - const seen = new Map() - for (const source of getPreparedSources(preparedValue, client, hookName)) { - const token = getSourceObjectToken(source) - const previous = seen.get(source.id) ?? prior?.get(source.id) - if (previous !== undefined && previous !== token) { - release() - throw new Error( - `[${hookName}] Source Collection "${source.id}" was replaced by a different Collection with the same ID while this hook is mounted. Unmount the hook and clean up its previous source and client scope before reusing the ID.`, - ) - } - seen.set(source.id, token) - } - const claimed = prior ?? new Map() - for (const [id, token] of seen) claimed.set(id, token) - if (client) bindings.byClient.set(client, claimed) -} diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index ca4e0b8da5..66223831cd 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -2,31 +2,21 @@ import { useCallback, useRef, useSyncExternalStore } from 'react' import { - assertLiveQueryWindowManyResult, - compareLiveQueryWindowDependencies, - createLiveQueryCollection, - createLiveQueryWindowController, + bindLiveQueryWindow, + createLiveQuerySourceIdClaims, fetchNextLiveQueryWindowPage, - getLiveQueryWindowCollectionWarning, - getLiveQueryWindowInputKind, - liveQueryWindowMatches, - normalizeLiveQueryWindowPageSize, - resolveLiveQueryWindowInput, - shouldPreserveLiveQueryWindowPageCount, + identifyLiveQuery, + isCollection, + readLiveQueryWindowPageShape, + resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' import { - claimSourceIds, - createSourceIdBindings, - resumeSyncStarts, -} from './source-id-bindings' -import { + getIdentityScope, prepareDerivedQuery, prepareQueryValue, warnDeprecatedDepsArray, - warnUnhashableDerivedIdentity, } from './useLiveQuery' -import type { SourceIdBindings } from './source-id-bindings' import type { DerivedIdentityProfiler, LiveQueryKey, @@ -41,13 +31,12 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryPersistedStatus, - LiveQueryWindowController, + LiveQuerySourceIdClaims, + LiveQueryWindowBinding, NonSingleResult, QueryBuilder, } from '@tanstack/db' -// Live queries created here are cleaned up immediately (0 disables GC). -const DEFAULT_GC_TIME_MS = 1 const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) // Keep the generic parameter for existing typed config wrappers. @@ -108,46 +97,15 @@ type EnabledLiveQueryReturn = ReturnType< typeof useLiveQuery > -type InfiniteQueryRenderState = { - inputKind: `collection` | `query` - inputCollection: Collection | null - inputQuery: unknown - client: DbClient | undefined - identityMode: `collection` | `queryKey` | `legacyDeps` | `derived` - dependencies: Array | null - pageSize: number - initialPageParam: number - collection: Collection - controller: LiveQueryWindowController - warning: string | null - warned: boolean +/** One render's window binding and what its commit still owes. */ +type InfiniteQueryRender = { + binding: LiveQueryWindowBinding + /** The query callback whose identity this render derived, if any. */ + derivedQuery: unknown deferredCollections: Set< CollectionImplType > -} - -type RenderedWindowCollection = { - client: DbClient | undefined - dependencies: Array - collection: Collection - deferredCollections: Set< - CollectionImplType - > -} - -/** - * Whether a recorded query identity matches the current one. Legacy deps - * compare by reference. Derived identities and query keys compare by structure. - */ -function queryIdentityMatches( - comparison: { changed: boolean; structurallyEqual: boolean }, - sameClient: boolean, - usesLegacyDeps: boolean, -): boolean { - return ( - sameClient && - (usesLegacyDeps ? !comparison.changed : comparison.structurallyEqual) - ) + warned: boolean } /** @@ -185,260 +143,142 @@ export function useLiveInfiniteQuery( config: UseLiveInfiniteQueryConfig, deps?: Array, ): UseLiveInfiniteQueryReturn { - if (`getNextPageParam` in config) { - throw new Error( - `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`, - ) - } - const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) - const initialPageParam = config.initialPageParam ?? 0 + const pageShape = readLiveQueryWindowPageShape(config) const contextDbClient = useOptionalDbClient() const dbClient = config.client ?? contextDbClient + const inputIsCollection = isCollection(queryFnOrCollection) - const inputIsCollection = - getLiveQueryWindowInputKind(queryFnOrCollection) === `collection` - - const committedRef = useRef(null) + const committedRef = useRef(null) const committed = committedRef.current - // Like useLiveQuery's instance memo, keep the window collection built by the - // latest render. A second render before commit, such as a StrictMode double - // render, reuses it instead of starting another one. Only the collection is - // kept. Each render still builds its own controller and page count from the - // committed state, which an uncommitted render never changes. - const renderedCollectionRef = useRef(null) - const sourceIdsRef = useRef(null) - const inputKind = inputIsCollection ? `collection` : `query` + // Like useLiveQuery's instance memo, keep the latest render whose binding + // built a window collection. A second render before commit, such as a + // StrictMode double render, reuses that collection instead of starting + // another. Each render still builds its own controller and page count from + // the committed binding, which an uncommitted render never changes. + const renderedRef = useRef(null) + const sourceIdsRef = useRef(null) const derivedIdentityProfilerRef = useRef({ renderCount: 0, totalMs: 0, maxMs: 0, warned: false, }) - const legacyUnhashableIdentityRef = useRef>([ - `legacy-unhashable`, - ]) const deferredCollections = new Set< CollectionImplType >() + const identityScope = () => + getIdentityScope( + (sourceIdsRef.current ??= createLiveQuerySourceIdClaims()), + dbClient, + `useLiveInfiniteQuery`, + deferredCollections, + ) + // An empty key is absent, as it is for a DbClient preload. + const queryKey = config.queryKey?.length ? config.queryKey : undefined + const usesLegacyDeps = + !inputIsCollection && queryKey === undefined && deps !== undefined let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue - let identityDeps: ReadonlyArray = [] - let identityMode: InfiniteQueryRenderState[`identityMode`] = `collection` + let dependencies: ReadonlyArray | undefined + let derivedQuery: unknown = undefined if (!inputIsCollection) { - if (config.queryKey !== undefined) { - identityMode = `queryKey` - identityDeps = config.queryKey - } else if (deps !== undefined) { - identityMode = `legacyDeps` - identityDeps = deps + if (queryKey !== undefined) { + dependencies = queryKey + } else if (usesLegacyDeps) { + dependencies = deps warnDeprecatedDepsArray(`useLiveInfiniteQuery`) } else if ( - committed?.identityMode === `derived` && - committed.inputQuery === queryFnOrCollection && - committed.client === dbClient + committed !== null && + committed.derivedQuery === queryFnOrCollection && + committed.binding.scope === dbClient ) { - identityMode = `derived` - identityDeps = committed.dependencies ?? [] + // The same callback in the same client scope keeps its identity. + derivedQuery = queryFnOrCollection + dependencies = committed.binding.dependencies } else { - identityMode = `derived` const preparation = prepareDerivedQuery( queryFnOrCollection, - dbClient, derivedIdentityProfilerRef.current, - deferredCollections, + identityScope(), ) preparedQueryValue = preparation.value - claimSourceIds( - (sourceIdsRef.current ??= createSourceIdBindings()), - preparation.value, + derivedQuery = queryFnOrCollection + dependencies = preparation.identityDeps + } + } + + // The binding calls this only when it compiles a new window collection. + const compileQuery = () => { + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + queryFnOrCollection, dbClient, - `useLiveInfiniteQuery`, - () => resumeSyncStarts(deferredCollections), + deferredCollections, ) - if (preparation.status === `hashable`) { - identityDeps = preparation.identityDeps - } else { - warnUnhashableDerivedIdentity(preparation.error) - identityDeps = legacyUnhashableIdentityRef.current + if (usesLegacyDeps) { + // A dependency list decides reuse, so check the query when it + // compiles: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery(preparedQueryValue, identityScope()) } } + return preparedQueryValue } - const usesLegacyDeps = - !inputIsCollection && config.queryKey === undefined && deps !== undefined - const dependencyComparison = compareLiveQueryWindowDependencies( - committed?.dependencies, - identityDeps, + const rendered = renderedRef.current + const binding = bindLiveQueryWindow( + committed?.binding ?? null, + { + ...pageShape, + input: + typeof queryFnOrCollection === `function` + ? compileQuery + : queryFnOrCollection, + dependencies, + scope: dbClient, + recompile: usesLegacyDeps ? `dependencies` : `identity`, + }, + rendered?.binding, ) - const sameClient = committed?.client === dbClient - const dependenciesChanged = - !inputIsCollection && - !queryIdentityMatches(dependencyComparison, sameClient, usesLegacyDeps) - const dependenciesStructurallyEqual = - usesLegacyDeps && sameClient && dependencyComparison.structurallyEqual - const needsNewCollection = - committed === null || - committed.inputKind !== inputKind || - (inputIsCollection && committed.inputCollection !== queryFnOrCollection) || - dependenciesChanged - const pageShapeChanged = - committed === null || - committed.pageSize !== pageSize || - committed.initialPageParam !== initialPageParam - const needsNewController = - committed === null || needsNewCollection || pageShapeChanged - - let renderState = committed - if (needsNewController) { - let collection = committed?.collection - let warning: string | null = null - let startInRender = false - let suppliedCollection = false - let createdCollection = false - - const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ - hasPreviousController: committed !== null, - previousInputKind: committed?.inputKind, - inputKind, - sameCollection: - inputIsCollection && committed?.inputCollection === queryFnOrCollection, - dependenciesChanged, - dependenciesStructurallyEqual, - pageShapeChanged, - }) - const previousPageCount = committed - ? Math.max(1, committed.controller.getSnapshot().pages.length) - : 1 - const initialPageCount = canPreservePageCount ? previousPageCount : 1 - // The peek-ahead window for every retained page. - const requiredLimit = initialPageCount * pageSize + 1 - - if (needsNewCollection) { - let inputValue = queryFnOrCollection - if (!inputIsCollection) { - if (preparedQueryValue === unpreparedQueryValue) { - preparedQueryValue = prepareQueryValue( - queryFnOrCollection, - dbClient, - deferredCollections, - ) - } - inputValue = () => preparedQueryValue - } - const rendered = renderedCollectionRef.current - if ( - !inputIsCollection && - rendered !== null && - queryIdentityMatches( - compareLiveQueryWindowDependencies( - rendered.dependencies, - identityDeps, - ), - rendered.client === dbClient, - usesLegacyDeps, - ) && - rendered.collection.status !== `cleaned-up` && - rendered.collection.status !== `error` && - liveQueryWindowMatches(rendered.collection, requiredLimit) - ) { - // An earlier render built this collection and nothing has committed - // since. Its window is exactly the retained pages, so its first - // rows are correct. Sources it deferred still resume at commit. A - // collection in terminal error, such as after its source restarted, - // cannot serve the query again, so a new one replaces it. - collection = rendered.collection - for (const deferred of rendered.deferredCollections) { - deferredCollections.add(deferred) - } - } else { - const input = resolveLiveQueryWindowInput(inputValue) - if (input.kind === `collection`) { - collection = input.collection - suppliedCollection = true - // A supplied collection is never reused, so release the last one. - renderedCollectionRef.current = null - } else { - // Wrap the query with the peek-ahead window for every retained page, - // so a collection that starts syncing now never publishes fewer rows - // than the controller's pages. The controller grows the limit via - // setWindow. - collection = createLiveQueryCollection({ - query: input.query.limit(requiredLimit).offset(0), - gcTime: DEFAULT_GC_TIME_MS, - }) - startInRender = true - createdCollection = true - } - } - } - - if (!collection) { - throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`) - } - if (inputIsCollection) { - warning = - getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null - } else { - assertLiveQueryWindowManyResult(collection) - } - // Like useLiveQuery, start sync during render once the input is valid. A - // render reads only rows the sources already hold, such as a preloaded - // collection's, and sends no request until the subscription commits. GC - // reclaims a render that never commits. A supplied window that the - // controller must still adjust waits for the subscription. - if (suppliedCollection) { - startInRender = liveQueryWindowMatches(collection, requiredLimit) - } - if (startInRender) collection.startSyncImmediate() - // Keep a created collection for the next render only once it validated and - // started, so a retry after a startup error builds a new one and throws. - if (createdCollection) { - renderedCollectionRef.current = { - client: dbClient, - dependencies: [...identityDeps], - collection, - deferredCollections, + let currentRenderState = committed + if (binding !== committed?.binding) { + const newCollection = binding.collection !== committed?.binding.collection + if (newCollection && binding.collection === rendered?.binding.collection) { + // Sources the earlier render deferred still resume at this commit. + for (const deferred of rendered.deferredCollections) { + deferredCollections.add(deferred) } } - - renderState = { - inputKind, - inputCollection: inputIsCollection ? collection : null, - inputQuery: inputIsCollection ? null : queryFnOrCollection, - client: dbClient, - identityMode, - dependencies: inputIsCollection ? null : [...identityDeps], - pageSize, - initialPageParam, - collection, - controller: createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, - }), - warning, - warned: false, + currentRenderState = { + binding, + derivedQuery, deferredCollections, + warned: false, + } + // Keep a created collection for the next render only once it validated + // and started, so a retry after a startup error builds a new one. + if (binding.supplied) renderedRef.current = null + else if (newCollection && binding.ownsCollection) { + renderedRef.current = currentRenderState } } - const currentRenderState = renderState! - const controller = currentRenderState.controller + const renderState = currentRenderState! + const controller = renderState.binding.controller const subscribe = useCallback( (onStoreChange: () => void) => { const unsubscribe = controller.subscribe(onStoreChange) - committedRef.current = currentRenderState - if (currentRenderState.warning && !currentRenderState.warned) { - currentRenderState.warned = true - console.warn(currentRenderState.warning) + committedRef.current = renderState + if (renderState.binding.warning && !renderState.warned) { + renderState.warned = true + console.warn(renderState.binding.warning) } - resumeSyncStarts(currentRenderState.deferredCollections) + resumeDeferredLiveQueryCollections(renderState.deferredCollections) return unsubscribe }, - [controller, currentRenderState], + [controller, renderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 853d4ea68b..f030ee1d4b 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -4,22 +4,23 @@ import { useRef, useSyncExternalStore } from 'react' import { UnhashableQueryIRError, createLiveQueryObserver, + createLiveQuerySourceIdClaims, deepEquals, - getPreparedLiveQueryIdentity, + getLiveQueryKey, + getSourceObjectToken, getStableValueHash, + identifyLiveQuery, isCollection, prepareLiveQueryValue, resolveLiveQueryValue, + resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' import { setLiveQueryResultInfo } from './live-query-internals' import { shouldWarnInDevelopment } from './development' import { - claimSourceIds, - createSourceIdBindings, getPreparedSources, - getSourceObjectToken, - resumeSyncStarts, + getUnboundDescriptorReporter, } from './source-id-bindings' import type { Collection, @@ -31,8 +32,10 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + LiveQueryIdentityScope, LiveQueryObserver, LiveQueryPersistedStatus, + LiveQuerySourceIdClaims, NonSingleResult, QueryBuilder, SingleResult, @@ -47,7 +50,6 @@ const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10 const DERIVED_IDENTITY_TOTAL_WARN_MS = 50 const warnedDepsCallsites = new Set() const warnedDerivedIdentityCallsites = new Set() -const warnedUnhashableIdentityCallsites = new Set() const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) type SuspenseCollection = Collection @@ -227,14 +229,6 @@ function warnDerivedIdentityHotPath( ) } -function getExplicitQueryKey(value: unknown): LiveQueryKey | undefined { - return value && - typeof value === `object` && - Array.isArray((value as { queryKey?: unknown }).queryKey) - ? (value as { queryKey: LiveQueryKey }).queryKey - : undefined -} - function getExplicitDbClient(value: unknown): DbClient | undefined { return value && typeof value === `object` && @@ -252,43 +246,57 @@ export function prepareQueryValue( return prepareLiveQueryValue(value, dbClient, deferredCollections) } -type DerivedQueryPreparation = - | { - status: `hashable` - value: unknown - identityDeps: Array - } - | { - status: `unhashable` - value: unknown - error: UnhashableQueryIRError - } +type DerivedQueryPreparation = { + value: unknown + identityDeps: Array +} + +type HookIdentityScope = LiveQueryIdentityScope & { + deferredCollections: Set> +} +/** How a hook checks a prepared query whose identity it compares. */ +export function getIdentityScope( + claims: LiveQuerySourceIdClaims | undefined, + dbClient: DbClient | undefined, + hookName: string, + deferredCollections: Set>, +): HookIdentityScope { + return { + claims, + client: dbClient, + bindingName: hookName, + deferredCollections, + onUnboundDescriptor: getUnboundDescriptorReporter(dbClient, hookName), + } +} + +/** + * Prepare a query without `queryKey`, derive its identity, and claim its + * source Collection IDs. A query whose IR has no stable identity throws + * `UnhashableQueryIRError`; either rejection releases the render's deferred + * sync starts. + */ export function prepareDerivedQuery( value: unknown, - dbClient: DbClient | undefined, profiler: DerivedIdentityProfiler, - deferredCollections: Set>, + scope: HookIdentityScope, ): DerivedQueryPreparation { const shouldProfile = shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && process.env.NODE_ENV !== `production` const start = shouldProfile ? getCurrentTime() : 0 - const preparedValue = prepareQueryValue(value, dbClient, deferredCollections) + const preparedValue = prepareQueryValue( + value, + scope.client, + scope.deferredCollections, + ) try { - const identity = getPreparedLiveQueryIdentity(preparedValue) return { - status: `hashable`, value: preparedValue, - identityDeps: [`derived`, identity], + identityDeps: [`derived`, identifyLiveQuery(preparedValue, scope)], } - } catch (error) { - if (error instanceof UnhashableQueryIRError) { - return { status: `unhashable`, value: preparedValue, error } - } - - throw error } finally { if (shouldProfile) { const durationMs = getCurrentTime() - start @@ -300,29 +308,6 @@ export function prepareDerivedQuery( } } -export function warnUnhashableDerivedIdentity( - error: UnhashableQueryIRError, -): void { - if (!( - shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && - process.env.NODE_ENV !== `production` - )) { - return - } - - const callsite = getWarningCallsite(4) - if (warnedUnhashableIdentityCallsites.has(callsite)) { - return - } - warnedUnhashableIdentityCallsites.add(callsite) - - console.warn( - `[useLiveQuery] This query cannot derive a stable identity because ${error.reason} at ${error.path}. ` + - `It will keep the legacy mount-stable behavior for now. Add queryKey: [...] to make captured values reactive. ` + - `Unhashable queries without queryKey will throw in 1.0.`, - ) -} - /** * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch @@ -730,7 +715,6 @@ function createHookInstance(dbClient: DbClient | undefined) { deps: null as Array | null, config: null as unknown, client: dbClient, - legacyUnhashableIdentity: [`legacy-unhashable`] as Array, derivedIdentityProfiler: { renderCount: 0, totalMs: 0, @@ -740,7 +724,7 @@ function createHookInstance(dbClient: DbClient | undefined) { deferredCollections: new Set< CollectionImpl >(), - sourceIds: createSourceIdBindings(), + sourceIds: createLiveQuerySourceIdClaims(), observer: null as LiveQueryObserver | null, queryHash: undefined as string | undefined, suspenseKey: undefined as string | undefined, @@ -765,10 +749,10 @@ function useLiveQueryImpl( const instanceRef = useRef | null>(null) const instance = (instanceRef.current ??= createHookInstance(dbClient)) const resumeDeferredCollections = () => - resumeSyncStarts(instance.deferredCollections) + resumeDeferredLiveQueryCollections(instance.deferredCollections) const queryKey = !inputIsCollection - ? getExplicitQueryKey(configOrQueryOrCollection) + ? getLiveQueryKey(configOrQueryOrCollection) : undefined let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue @@ -781,49 +765,45 @@ function useLiveQueryImpl( streamIdentity = [`queryKey`, queryKey] } else if (deps !== undefined) { identityDeps = resolvedDeps - // Deps decide reuse. Only hydration and Suspense read the query hash; the - // development warning about unhashable queries still derives it. - if ( - dbClient || - forSuspense || - (shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && - process.env.NODE_ENV !== `production`) - ) - try { - preparedQueryValue = prepareQueryValue( - configOrQueryOrCollection, - dbClient, - instance.deferredCollections, - ) - streamIdentity = [ - `deps`, - resolvedDeps, - getPreparedLiveQueryIdentity(preparedQueryValue), - ] - } catch (error) { - if (!(error instanceof UnhashableQueryIRError)) throw error - warnUnhashableDerivedIdentity(error) - identityError = error - } + // Deps decide reuse. Only hydration and Suspense read the query hash + // before compiling; every compile checks the identity below. + if (dbClient || forSuspense) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + instance.deferredCollections, + ) + streamIdentity = [ + `deps`, + resolvedDeps, + identifyLiveQuery( + preparedQueryValue, + getIdentityScope( + undefined, + dbClient, + `useLiveQuery`, + instance.deferredCollections, + ), + ), + ] + } } else if (inputIsCollection) { identityDeps = [] streamIdentity = [`collection`, configOrQueryOrCollection.id] } else { const preparation = prepareDerivedQuery( configOrQueryOrCollection, - dbClient, instance.derivedIdentityProfiler, - instance.deferredCollections, + getIdentityScope( + instance.sourceIds, + dbClient, + `useLiveQuery`, + instance.deferredCollections, + ), ) preparedQueryValue = preparation.value - if (preparation.status === `hashable`) { - identityDeps = preparation.identityDeps - streamIdentity = preparation.identityDeps - } else { - warnUnhashableDerivedIdentity(preparation.error) - identityDeps = instance.legacyUnhashableIdentity - identityError = preparation.error - } + identityDeps = preparation.identityDeps + streamIdentity = preparation.identityDeps } let queryHash: string | undefined @@ -844,20 +824,6 @@ function useLiveQueryImpl( warnDeprecatedDepsArray() } - if ( - queryKey === undefined && - deps === undefined && - preparedQueryValue !== unpreparedQueryValue - ) { - claimSourceIds( - instance.sourceIds, - preparedQueryValue, - dbClient, - `useLiveQuery`, - resumeDeferredCollections, - ) - } - const canReuseSuspenseKey = forSuspense && !inputIsCollection && @@ -948,6 +914,19 @@ function useLiveQueryImpl( instance.deferredCollections, ) } + if (queryKey === undefined && deps !== undefined) { + // A dependency list decides reuse, so check the query when it + // compiles: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery( + preparedQueryValue, + getIdentityScope( + instance.sourceIds, + dbClient, + `useLiveQuery`, + instance.deferredCollections, + ), + ) + } if (!dbClient) { getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 19600d4867..27d42f9bad 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -28,6 +28,7 @@ import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' +import { rejectReadsAfterRenderError } from './rejected-binding' import type { RenderHookResult } from '@testing-library/react' import type { ConformanceResult, @@ -47,10 +48,13 @@ function writer(collection: any) { } } -function makeSource(initialData: ReadonlyArray) { +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +) { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-react-${sourceSeq++}`, + id: collectionId ?? `conformance-react-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -158,13 +162,16 @@ function mountControllable

( useLiveQuery((q: any) => build(q, param), [param]), { initialProps: { param: initial } }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook) return { ...handle, async setParam(param: P) { - await act(async () => { - hook.rerender({ param }) - }) + await guard(() => + act(async () => { + hook.rerender({ param }) + }), + ) await handle.flush() }, } @@ -208,6 +215,8 @@ function makeHandle(hook: RenderHookResult) { const reactDriver: LiveQueryDriver = { name: `react`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, disabledRepresentation: `absent`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, @@ -272,13 +281,15 @@ it(`publishes a synchronous full-source ordered limit in the first layout commit ]) const commits: Array<{ ids: Array; status: string }> = [] const hook = renderHook(() => { - const result = useLiveQuery((q) => - q - .from({ row: source.collection }) - .fn.where(({ row }) => row.rank > 0) - .orderBy(({ row }) => row.rank) - .limit(2), - ) + const result = useLiveQuery({ + queryKey: [`full-source-ordered-limit`], + query: (q) => + q + .from({ row: source.collection }) + .fn.where(({ row }) => row.rank > 0) + .orderBy(({ row }) => row.rank) + .limit(2), + }) useLayoutEffect(() => { commits.push({ ids: result.data.map(({ id }) => id), diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index a00fd70f2a..0cb7eaf256 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -20,6 +20,7 @@ import { mockSyncCollectionOptions } from '../../db/tests/utils' import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { rejectReadsAfterRenderError } from './rejected-binding' import type { RenderHookResult } from '@testing-library/react' import type { InfiniteQueryConfig, @@ -31,11 +32,14 @@ import type { QueryBuild } from '../../db/tests/conformance/contract' let sourceSequence = 0 -function makeSource(initialData: ReadonlyArray) { +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +) { const collection = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `infinite-conformance-react-${sourceSequence++}`, + id: collectionId ?? `infinite-conformance-react-${sourceSequence++}`, getKey: (row) => row.id, initialData: [...initialData], }), @@ -150,11 +154,12 @@ function mountControllable

( ), { initialProps: { param: initial } }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, setParamSync(param: P) { - act(() => hook.rerender({ param })) + guard(() => act(() => hook.rerender({ param }))) }, } } @@ -179,11 +184,12 @@ function mountCollectionControllable( useRecorded(useLiveInfiniteQuery(collection, config as any), log), { initialProps: { collection: initial } }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, replaceCollectionSync(collection: any) { - act(() => hook.rerender({ collection })) + guard(() => act(() => hook.rerender({ collection }))) }, } } @@ -198,11 +204,12 @@ function mountConfigControllable( useRecorded(useLiveInfiniteQuery(build as any, config as any), log), { initialProps: { config: initial } }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, setConfigSync(config: InfiniteQueryConfig) { - act(() => hook.rerender({ config })) + guard(() => act(() => hook.rerender({ config }))) }, } } @@ -229,17 +236,20 @@ function mountInputControllable( }, }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, setInputKindSync(kind: `collection` | `query`) { - act(() => hook.rerender({ kind })) + guard(() => act(() => hook.rerender({ kind }))) }, } } const reactInfiniteDriver: InfiniteQueryDriver = { name: `react`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, gt, makeSource, makeOnDemandSource: (data, delay) => 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..3d5dbf0fc6 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 @@ -256,7 +256,10 @@ describe(`on-demand sources match useLiveQuery`, () => { .orderBy(({ items }: any) => items.rank, `desc`) const result: any = hook === `live` - ? useLiveQuery((q: any) => query(q).limit(PAGE_SIZE + 1), [fail]) + ? useLiveQuery({ + queryKey: [`startup-error`, fail], + query: (q: any) => query(q).limit(PAGE_SIZE + 1), + }) : useLiveInfiniteQuery(query, { pageSize: PAGE_SIZE, queryKey: [`startup-error`, fail], diff --git a/packages/react-db/tests/rejected-binding.ts b/packages/react-db/tests/rejected-binding.ts new file mode 100644 index 0000000000..24b5561d3a --- /dev/null +++ b/packages/react-db/tests/rejected-binding.ts @@ -0,0 +1,38 @@ +import type { RenderHookResult } from '@testing-library/react' + +/** + * React unmounts a root whose render throws without an error boundary, so a + * rejected reconfiguration leaves no binding to read. The returned guard runs + * a reconfiguration and records its rejection, whether `act` throws it or + * rejects with it. Every later result read rethrows that rejection instead of + * returning the unmounted hook's last committed value. + */ +export function rejectReadsAfterRenderError( + hook: RenderHookResult, +): (reconfigure: () => T) => T { + let rejection: { error: unknown } | undefined + const result = hook.result + Object.defineProperty(hook, `result`, { + value: { + get current() { + if (rejection) throw rejection.error + return result.current + }, + }, + }) + const record = (error: unknown): never => { + rejection ??= { error } + throw error + } + return (reconfigure) => { + try { + const value = reconfigure() + // React's async `act` returns a thenable, not a Promise. + return typeof (value as { then?: unknown } | null)?.then === `function` + ? (Promise.resolve(value).catch(record) as typeof value) + : value + } catch (error) { + return record(error) + } + } +} 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..29ebdc773f 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -17,7 +17,12 @@ * entries must remain distinct, including before the first hook commits. * A rejected render must release any sync-start deferral it acquired for a * shared client descriptor, so a later direct reader can start that Collection. - * This remains true when earlier deferred sync starts throw: each pending + * The release history grammar crosses every rejection cause (a source ID + * collision, a query without a stable IR identity, a descriptor factory that + * throws while the query binds its sources), every hook path (live and + * infinite, with derived identity or a dependency list), and both direct + * readers (another hook over the Collection, and `startSyncImmediate()`). The + * release must also hold when earlier deferred sync starts throw: each pending * Collection is attempted before the first startup error is rethrown. * The bounded histories below distinguish this law from a hash-only check, * which would miss A(id=x) -> C(id=y) -> B(id=x), a changed predicate, and @@ -31,6 +36,7 @@ import { act, render, renderHook, waitFor } from '@testing-library/react' import { DbClient, + SourceCollectionIdReuseError, collectionOptions, createCollection, eq, @@ -92,12 +98,202 @@ function checkRerender( rerender: () => void, ) { if (expectedCollision(history, next)) { - expect(rerender).toThrow(/source Collection .*same ID/i) + expect(rerender).toThrow(SourceCollectionIdReuseError) } else { expect(rerender).not.toThrow() } } +/* + * Release law. A hook in one DbClient scope first reads only its own source, + * then rerenders into a rejected query that also reads a shared descriptor. + * The rejected render may defer the shared Collection's sync start, and it + * must release that deferral: the source stays unstarted until a direct + * reader asks, and that reader then starts it exactly once and reads its row. + */ +type ReleaseStep = { current: ReturnType; reject: boolean } + +const releaseCauses: Record< + string, + ( + shared: any, + failing: any, + ) => { + rejected: (step: ReleaseStep) => ReleaseStep + query: (q: any, step: ReleaseStep, ordered: boolean) => any + } +> = { + 'a source ID collision': (shared) => ({ + rejected: () => ({ + current: source(`settings`, `replacement`), + reject: true, + }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + }), + 'a query without a stable IR identity': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + .fn.where(() => true) + : q.from({ settings: step.current }), + ordered, + ), + }), + 'a descriptor factory that throws': (shared, failing) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + .join({ failing }, ({ settings, failing: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + }), +} + +function orderIf(query: any, ordered: boolean) { + return ordered ? query.orderBy(({ settings }: any) => settings.rank) : query +} + +const releaseHooks: Record< + string, + { + ordered: boolean + use: (query: (q: any) => any, deps: Array) => void + } +> = { + 'useLiveQuery with derived identity': { + ordered: false, + use: (query) => void useLiveQuery(query), + }, + 'useLiveQuery with a dependency list': { + ordered: false, + use: (query, deps) => void useLiveQuery(query, deps), + }, + 'useLiveInfiniteQuery with derived identity': { + ordered: true, + use: (query) => void useLiveInfiniteQuery(query, { pageSize: 1 }), + }, + 'useLiveInfiniteQuery with a dependency list': { + ordered: true, + use: (query, deps) => + void useLiveInfiniteQuery(query, { pageSize: 1 }, deps), + }, +} + +const releaseReaders: Record< + string, + (shared: ReturnType) => Promise +> = { + 'another hook over the Collection': async (shared) => { + const reader = renderHook(() => useLiveQuery(shared)) + await act(async () => {}) + return reader.result.current.data[0]?.value + }, + 'startSyncImmediate()': async (shared) => { + shared.startSyncImmediate() + await act(async () => {}) + return shared.toArray[0]?.value + }, +} + +describe(`React source ID reuse: rejected renders release deferred sync starts`, () => { + let sequence = 0 + for (const [causeName, makeCause] of Object.entries(releaseCauses)) { + for (const [hookName, hook] of Object.entries(releaseHooks)) { + for (const [readerName, read] of Object.entries(releaseReaders)) { + it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source`, async () => { + const client = new DbClient() + const id = `release-shared-${sequence++}` + let syncStarts = 0 + const shared = collectionOptions(id, () => ({ + id, + getKey: (row: Row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncStarts++ + begin() + write({ + type: `insert`, + value: { id: `one`, value: `shared`, rank: 1 }, + }) + commit() + markReady() + }, + }, + })) + const failing = collectionOptions(`${id}-failing`, () => { + throw new Error(`descriptor factory failed`) + }) + const cause = makeCause(shared, failing) + function View({ step }: { step: ReleaseStep }) { + hook.use( + (q) => cause.query(q, step, hook.ordered), + [step.current, step.reject], + ) + return

Query
+ } + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + try { + const initial = { + current: source(`settings`, `first`), + reject: false, + } + const root = render( + + + + + , + ) + await act(async () => {}) + root.rerender( + + + + + , + ) + expect(root.getByText(`Rejected`)).toBeDefined() + expect(syncStarts).toBe(0) + + const value = await read(client.collection(shared) as any) + expect(value).toBe(`shared`) + expect(syncStarts).toBe(1) + } finally { + consoleError.mockRestore() + } + }) + } + } + } +}) + describe(`React source ID reuse`, () => { it(`releases a later shared source when earlier deferred sync starts fail`, async () => { const client = new DbClient() @@ -190,76 +386,6 @@ describe(`React source ID reuse`, () => { } }) - it(`releases a shared descriptor's sync deferral when a collision tears down the hook`, async () => { - const client = new DbClient() - const first = source(`settings`, `first`) - const replacement = source(`settings`, `replacement`) - let syncStarts = 0 - const descriptor = collectionOptions(`shared-after-collision`, () => ({ - id: `shared-after-collision`, - getKey: (row: Row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - syncStarts++ - begin() - write({ - type: `insert`, - value: { id: `one`, value: `shared`, rank: 1 }, - }) - commit() - markReady() - }, - }, - })) - const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - - function View({ - current, - includeShared, - }: { - current: typeof first - includeShared: boolean - }) { - useLiveQuery({ - client, - query: (q) => - includeShared - ? q - .from({ settings: current }) - .join({ shared: descriptor }, ({ settings, shared }) => - eq(settings.id, shared.id), - ) - : q.from({ settings: current }), - }) - return
Query
- } - - try { - const root = render( - - - , - ) - await act(async () => {}) - root.rerender( - - - , - ) - expect(root.getByText(`Rejected`)).toBeDefined() - expect(syncStarts).toBe(0) - - const shared = client.collection(descriptor) - const reader = renderHook(() => useLiveQuery(shared)) - await act(async () => {}) - expect(reader.result.current.status).toBe(`ready`) - expect(reader.result.current.data[0]?.value).toBe(`shared`) - expect(syncStarts).toBe(1) - } finally { - consoleError.mockRestore() - } - }) - it(`rejects a different active source with the same ID before exposing stale rows`, async () => { const first = source(`settings`, `first`) const second = source(`settings`, `second`) @@ -344,7 +470,7 @@ describe(`React source ID reuse`, () => { ), }), ), - ).toThrow(/source Collection .*same ID/i) + ).toThrow(SourceCollectionIdReuseError) }) it(`keeps a mounted hook's ID binding after source cleanup`, async () => { @@ -498,7 +624,7 @@ describe(`React source ID reuse`, () => { , ), - ).toThrow(/source Collection .*same ID/i) + ).toThrow(SourceCollectionIdReuseError) }) it(`remembers each client scope when one mounted hook switches clients`, async () => { @@ -688,76 +814,4 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { ), ) }) - - it(`releases a shared descriptor's sync deferral when a collision tears down the hook`, async () => { - const client = new DbClient() - const first = source(`settings`, `first`) - const replacement = source(`settings`, `replacement`) - let syncStarts = 0 - const descriptor = collectionOptions( - `infinite-shared-after-collision`, - () => ({ - id: `infinite-shared-after-collision`, - getKey: (row: Row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - syncStarts++ - begin() - write({ - type: `insert`, - value: { id: `one`, value: `shared`, rank: 1 }, - }) - commit() - markReady() - }, - }, - }), - ) - const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - function View({ - current, - includeShared, - }: { - current: typeof first - includeShared: boolean - }) { - useLiveInfiniteQuery( - (q) => - includeShared - ? q - .from({ settings: current }) - .join({ shared: descriptor }, ({ settings, shared }) => - eq(settings.id, shared.id), - ) - .orderBy(({ settings }) => settings.rank) - : q - .from({ settings: current }) - .orderBy(({ settings }) => settings.rank), - { pageSize: 1, client }, - ) - return
Query
- } - try { - const root = render( - - - , - ) - await act(async () => {}) - root.rerender( - - - , - ) - expect(root.getByText(`Rejected`)).toBeDefined() - expect(syncStarts).toBe(0) - const shared = client.collection(descriptor) - const reader = renderHook(() => useLiveQuery(shared)) - await act(async () => {}) - expect(reader.result.current.data[0]?.value).toBe(`shared`) - expect(syncStarts).toBe(1) - } finally { - consoleError.mockRestore() - } - }) }) diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 6d09e4152f..74af321b88 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { DbClient, Query, + UnhashableQueryIRError, coalesce, collectionOptions, count, @@ -3262,8 +3263,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).not.toBe(firstCollection) }) - it(`warns and preserves legacy behavior when a functional query has no queryKey`, () => { - const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + it(`throws when a functional query has no queryKey`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `derived-identity-functional-missing-key`, @@ -3283,15 +3283,7 @@ describe(`Query Collections`, () => { }), { initialProps: { minAge: 25 } }, ), - ).not.toThrow() - - const warnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes(`cannot derive a stable identity`), - ) - expect(warnings).toHaveLength(1) - expect(warnings[0]![0]).toContain(`queryKey`) - expect(warnings[0]![0]).toContain(`1.0`) - warnSpy.mockRestore() + ).toThrow(UnhashableQueryIRError) }) it(`uses runtime identity for opaque values in a structured query without queryKey`, () => { @@ -3328,41 +3320,6 @@ describe(`Query Collections`, () => { warnSpy.mockRestore() }) - it(`does not emit identity warnings in production`, () => { - const previousNodeEnv = process.env.NODE_ENV - process.env.NODE_ENV = `production` - const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const collection = createCollection( - mockSyncCollectionOptions({ - id: `derived-identity-production-warning`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - - let unmount: (() => void) | undefined - try { - ;({ unmount } = renderHook(() => - useLiveQuery({ - query: (q) => - q - .from({ people: collection }) - .fn.where(({ people }) => people.age > 25), - }), - )) - - expect( - warnSpy.mock.calls.some(([message]) => - String(message).includes(`cannot derive a stable identity`), - ), - ).toBe(false) - } finally { - unmount?.() - process.env.NODE_ENV = previousNodeEnv - warnSpy.mockRestore() - } - }) - it(`uses explicit queryKey for functional query variants`, async () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 25f535836f..f495d56b0a 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -7,6 +7,7 @@ import { } from '@testing-library/react' import { DbClient, + UnhashableQueryIRError, collectionOptions, createCollection, createLiveQueryCollection, @@ -596,7 +597,6 @@ describe(`useLiveSuspenseQuery`, () => { }) it(`requires queryKey for an opaque query during SSR streaming`, () => { - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) const descriptor = collectionOptions(`streamed-people`, () => ({ id: `streamed-people`, getKey: (person: Person) => person.id, @@ -625,11 +625,7 @@ describe(`useLiveSuspenseQuery`, () => { }), { wrapper }, ), - ).toThrow(/Provide an explicit serializable queryKey/) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining(`cannot derive a stable identity`), - ) - warn.mockRestore() + ).toThrow(UnhashableQueryIRError) }) it(`throws the original streamed query error`, async () => { diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 232543cb2d..777d6ab6ad 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -52,10 +52,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-solid-${sourceSeq++}`, + id: collectionId ?? `conformance-solid-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -225,6 +226,8 @@ function mountControllable

( const solidDriver: LiveQueryDriver = { name: `solid`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 40d937c652..963f6b8d5a 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -1,14 +1,7 @@ import { - assertLiveQueryWindowManyResult, - compareLiveQueryWindowDependencies, - createLiveQueryCollection, - createLiveQueryWindowController, + bindLiveQueryWindow, fetchNextLiveQueryWindowPage, - getLiveQueryWindowCollectionWarning, - liveQueryWindowMatches, - normalizeLiveQueryWindowPageSize, - resolveLiveQueryWindowInput, - shouldPreserveLiveQueryWindowPageCount, + readLiveQueryWindowPageShape, } from '@tanstack/db' import { tick, untrack } from 'svelte' // Type-only: used in `ReturnType` below. @@ -21,21 +14,13 @@ import type { Context, InferResultType, InitialQueryBuilder, + LiveQueryWindowBinding, NonSingleResult, QueryBuilder, - UtilsRecord, } from '@tanstack/db' -const DEFAULT_GC_TIME_MS = 1 - type MaybeGetter = T | (() => T) -type InternalCollection = Collection - -type PreviousController = { - getSnapshot: () => { pages: ReadonlyArray> } -} - type InfiniteQueryOptions = { pageSize?: number /** First result-page label, not a server cursor or remote offset. */ @@ -108,105 +93,22 @@ export function useLiveInfiniteQuery( config: InfiniteQueryOptions, deps: Array<() => unknown> = [], ): UseLiveInfiniteQueryReturn { - if (`getNextPageParam` in config) { - throw new Error( - `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`, - ) - } - let validatedCollection: InternalCollection | null = null - let previousController: PreviousController | null = null - let previousInput: ReturnType< - typeof resolveLiveQueryWindowInput - > | null = null - let previousDependencies: Array | null = null - let previousPageSize: number | null = null - let previousInitialPageParam: number | null = null - - const pageSize = $derived(normalizeLiveQueryWindowPageSize(config.pageSize)) - const initialPageParam = $derived(config.initialPageParam ?? 0) + // Reject an unsupported config at construction, not on first read. + readLiveQueryWindowPageShape(config) + let binding: LiveQueryWindowBinding | null = null + // Svelte reruns this only when something it reads changed: a dependency, + // the page shape, or state the query callback reads. const controller = $derived.by(() => { - const dependencies = deps.map((dependency) => dependency()) - - const input = resolveLiveQueryWindowInput(queryFnOrCollection) - const dependencyComparison = compareLiveQueryWindowDependencies( - previousDependencies, - dependencies, - ) - const dependenciesChanged = dependencyComparison.changed - const dependenciesStructurallyEqual = dependencyComparison.structurallyEqual - const pageShapeChanged = - previousPageSize !== pageSize || - previousInitialPageParam !== initialPageParam - const sameCollection = - input.kind === `collection` && - previousInput?.kind === `collection` && - previousInput.collection === input.collection - const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ - hasPreviousController: previousController !== null, - previousInputKind: previousInput?.kind, - inputKind: input.kind, - sameCollection, - dependenciesChanged, - dependenciesStructurallyEqual, - pageShapeChanged, - }) - const previousPageCount = previousController - ? Math.max(1, previousController.getSnapshot().pages.length) - : 1 - const initialPageCount = canPreservePageCount ? previousPageCount : 1 - // The peek-ahead window for every retained page. - const requiredLimit = initialPageCount * pageSize + 1 - - previousInput = input - previousDependencies = [...dependencies] - previousPageSize = pageSize - previousInitialPageParam = initialPageParam - - if (input.kind === `collection`) { - const collection = input.collection - const warning = getLiveQueryWindowCollectionWarning( - collection, - pageSize + 1, - ) - - if (validatedCollection !== collection) { - validatedCollection = collection - if (warning) console.warn(warning) - } - // Like useLiveQuery, start a supplied collection during construction when - // its rows are already correct. A window the controller must still adjust - // waits for the subscription. - if (liveQueryWindowMatches(collection, requiredLimit)) { - collection.startSyncImmediate() - } - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, - }) - previousController = currentController - return currentController - } - - const collection = createLiveQueryCollection({ - // Size the window for every retained page, so a collection that starts - // syncing now never publishes fewer rows than the controller's pages. - query: input.query.limit(requiredLimit).offset(0), - gcTime: DEFAULT_GC_TIME_MS, - }) - assertLiveQueryWindowManyResult(collection) - // Like useLiveQuery, start sync during construction once the query is - // valid. Construction reads only rows the sources already hold and sends - // no request until the subscribing effect attaches. - collection.startSyncImmediate() - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, + const previous = binding + binding = bindLiveQueryWindow(previous, { + ...readLiveQueryWindowPageShape(config), + input: queryFnOrCollection, + dependencies: deps.map((dependency) => dependency()), + recompile: `always`, }) - previousController = currentController - return currentController + if (binding !== previous && binding.warning) console.warn(binding.warning) + return binding.controller }) let snapshot = $state.raw(untrack(() => controller.getSnapshot())) diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 10c8967112..3700dee9fe 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -6,9 +6,11 @@ import { BaseQueryBuilder, UnhashableQueryIRError, createLiveQueryObserver, + createLiveQuerySourceIdClaims, getLiveQueryHash, getPublicCollection, getStableValueHash, + identifyLiveQuery, isCollection, isSingleResultCollection, prepareLiveQueryValue, @@ -337,6 +339,9 @@ export function useLiveQuery( deps: Array<() => unknown> = [], ): UseLiveQueryReturn | UseLiveQueryReturnWithCollection { const contextDbClient = useOptionalDbClient() + // Source Collection IDs this hook's client-backed queries read, kept until + // it unmounts. + const sourceIdClaims = createLiveQuerySourceIdClaims() const resolved = $derived.by(() => { // First check if the original parameter might be a getter @@ -405,16 +410,40 @@ export function useLiveQuery( ? toValue(configuredQueryKey) : undefined + // A DbClient matches preloaded and hydrated queries by identity hash, so + // a client-backed query without queryKey needs a stable IR identity and + // keeps each source Collection ID's object while the hook is mounted. + // Either rejection releases the sources this preparation deferred. + const derivedHash = + dbClient && !queryKey?.length + ? identifyLiveQuery( + preparedValue, + { + claims: sourceIdClaims, + client: dbClient, + bindingName: `useLiveQuery`, + deferredCollections, + }, + getLiveQueryHash, + ) + : undefined + let queryHash: string | undefined try { queryHash = deps.length > 0 && !queryKey ? getStableValueHash( - [`deps`, dependencyValues, getLiveQueryHash(preparedValue)], + [ + `deps`, + dependencyValues, + derivedHash ?? getLiveQueryHash(preparedValue), + ], `queryKey`, ) - : getLiveQueryHash(preparedValue, queryKey) + : (derivedHash ?? getLiveQueryHash(preparedValue, queryKey)) } catch (error) { + // A dependency value or a client-free query that cannot be hashed + // leaves hydration without a key; it does not reject the query. if (!(error instanceof UnhashableQueryIRError)) throw error if (queryKey !== undefined) throw error } @@ -534,8 +563,13 @@ export function useLiveQuery( } }) + // Every read goes through the compiled query, so a query the hook rejected + // throws on each read instead of serving the previous query's state. + const readResolved = () => resolved + return { get state() { + readResolved() return state }, get data() { @@ -549,30 +583,39 @@ export function useLiveQuery( return getPublicCollection(resolved.collection) }, get status() { + readResolved() return status as CollectionStatus }, get isLoading() { + readResolved() return status === `loading` }, get isReady() { + readResolved() return status === `ready` || status === `disabled` }, get persistedStatus() { + readResolved() return persistedStatus }, get isPersistedReady() { + readResolved() return persistedStatus === `ready` }, get persistedError() { + readResolved() return persistedError }, get isIdle() { + readResolved() return status === `idle` }, get isError() { + readResolved() return status === `error` }, get isCleanedUp() { + readResolved() return status === `cleaned-up` }, } diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index 0868687354..ffbb8c1df0 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -50,10 +50,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-svelte-${sourceSeq++}`, + id: collectionId ?? `conformance-svelte-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -216,6 +217,8 @@ function mountControllable

( const svelteDriver: LiveQueryDriver = { name: `svelte`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index 5ac61afa14..8f53b42709 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -2,25 +2,43 @@ * # A Svelte query releases independent descriptor sources * * A failed source start must not prevent a later source of the same query from - * starting. A query that fails while resolving descriptors may leave a source - * deferred, but a later direct reader owns fresh demand and can preload it. + * starting. A query that the hook rejects must release every sync start its + * preparation deferred: the source then follows its own start policy, and a + * later direct reader can start it. * - * The model has two independent source start counts. Both are zero while the - * hook is built. In the first history, the first start throws after the Svelte - * effect asks for rows; the second count must still become one and the failed - * effect must release its subscription. In the second - * history, the second factory throws during preparation. The first count stays - * zero until a direct reader preloads that source, then becomes one. The - * checkpoints are the public hook error and the source Collection's start - * count and ready status. The grammar covers two eager union sources with - * synchronous starts. A neighboring remount consumes an independent healthy - * query after the failed effect is retired. Its row comes from the healthy - * source alone. A synchronous unhandled effect error does not rerun that same - * Svelte effect when its query changes; recovery in place is not promised. - * Asynchronous source failures remain outside this model. + * The model has independent source start counts. In the first history, the + * first start throws after the Svelte effect asks for rows; the second count + * must still become one and the failed effect must release its subscription. + * The release grammar crosses three rejection causes for a client-backed + * query (a descriptor factory that throws during preparation, a query without + * a stable IR identity, and a different source object under a Collection ID + * the mounted hook already read), two source start policies, and three direct + * readers (`preload()`, `startSyncImmediate()`, and another hook). After the + * rejection, an eager (`startSync: true`) source has started once and a lazy + * source has not started; after the reader, each has started exactly once + * and is ready. This revises the earlier promise that a failed preparation + * "may leave a source deferred" until `preload()`: under that law + * `startSyncImmediate()` and another hook could never start the source. + * + * A rejection in a mounted hook must not serve the previous query: after the + * collision, each public field read separately throws. The checkpoints are + * the public hook error, each result field, and the source Collection's start + * count and status. A neighboring remount consumes an independent healthy + * query after the failed effect is retired. A synchronous unhandled effect + * error does not rerun that same Svelte effect when its query changes; + * recovery in place is not promised. Asynchronous source failures remain + * outside this model. */ import { describe, expect, it } from 'vitest' -import { DbClient, Query, collectionOptions } from '@tanstack/db' +import { + DbClient, + Query, + SourceCollectionIdReuseError, + UnhashableQueryIRError, + collectionOptions, + createCollection, + eq, +} from '@tanstack/db' import { flushSync } from 'svelte' import { useLiveQuery } from '../src/useLiveQuery.svelte.js' @@ -146,52 +164,151 @@ describe(`Svelte descriptor source release`, () => { } }) - it(`lets a direct reader start a source after preparation fails`, async () => { - const failure = new Error(`second factory failed`) - let firstStarts = 0 - const first = collectionOptions(`svelte-prepare-first`, () => ({ - id: `svelte-prepare-first`, - getKey: (row: Row) => row.id, - startSync: true, - sync: { - sync: ({ markReady }) => { - firstStarts++ - markReady() + type Policy = `eager` | `lazy` + type Reader = `preload()` | `startSyncImmediate()` | `another hook` + + // Each cause mounts a client-backed hook and rejects it; `step` drives a + // rejection that needs an earlier, accepted query. + const causes: Record< + string, + ( + shared: any, + client: DbClient, + ) => { rejected: Error | string; run: () => () => void } + > = { + 'a descriptor factory that throws during preparation': (shared, client) => { + const failing = collectionOptions(`${shared.id}-failing`, () => { + throw new Error(`descriptor factory failed`) + }) + const query = new Query().unionAll( + new Query() + .from({ shared }) + .select(({ shared: row }: any) => ({ id: row.id })), + new Query() + .from({ failing }) + .select(({ failing: row }: any) => ({ id: row.id })), + ) + return { + rejected: `descriptor factory failed`, + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ client, query }) + }) + }).toThrow(`descriptor factory failed`) + return () => dispose?.() }, + } + }, + 'a query without a stable IR identity': (shared, client) => ({ + rejected: `UnhashableQueryIRError`, + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ + client, + query: new Query().from({ shared }).fn.where(() => true), + }) + }) + }).toThrow(UnhashableQueryIRError) + return () => dispose?.() }, - })) - const second = collectionOptions( - `svelte-prepare-second`, - (): { - id: string - getKey: (row: Row) => string - sync: { sync: () => void } - } => { - throw failure + }), + 'a reused source Collection ID': (shared, client) => ({ + rejected: `SourceCollectionIdReuseError`, + run: () => { + const first = createCollection({ + id: `${shared.id}-settings`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const replacement = createCollection({ + id: first.id, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + let step = $state({ current: first, join: false }) + let result!: ReturnType + const dispose = $effect.root(() => { + // A getter re-reads `step`; the public types omit this config form. + result = useLiveQuery((() => ({ + client, + query: step.join + ? new Query() + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : new Query().from({ settings: step.current }), + })) as any) + }) + flushSync() + step = { current: replacement, join: true } + expect(() => flushSync()).toThrow(SourceCollectionIdReuseError) + // Each field, read on its own, rejects instead of serving the + // previous query's rows or readiness. + for (const field of [`data`, `state`, `status`, `isReady`] as const) { + expect(() => result[field]).toThrow(SourceCollectionIdReuseError) + } + return dispose }, - ) - const query = new Query().unionAll( - new Query().from({ first }).select(({ first: row }) => ({ id: row.id })), - new Query() - .from({ second }) - .select(({ second: row }) => ({ id: row.id })), - ) - const client = new DbClient() + }), + } - try { - expect(() => - $effect.root(() => { - useLiveQuery({ client, query }) - }), - ).toThrow(failure) - expect(firstStarts).toBe(0) - const direct = client.collection(first) - const read = direct.preload() - expect(firstStarts).toBe(1) - await read - expect(direct.status).toBe(`ready`) - } finally { - await client.cleanup() + const readers: Record Promise> = { + 'preload()': (source) => source.preload(), + 'startSyncImmediate()': async (source) => { + source.startSyncImmediate() + await Promise.resolve() + }, + 'another hook': async (source) => { + const dispose = $effect.root(() => { + useLiveQuery(source) + }) + flushSync() + await Promise.resolve() + dispose() + }, + } + + let sequence = 0 + for (const [causeName, makeCause] of Object.entries(causes)) { + for (const policy of [ + `eager`, + `lazy`, + ] as const satisfies ReadonlyArray) { + for (const [readerName, read] of Object.entries(readers)) { + it(`after ${causeName}, a ${policy} source follows its policy and ${readerName} starts it`, async () => { + const id = `svelte-release-${sequence++}` + let starts = 0 + const shared = collectionOptions(id, () => ({ + id, + getKey: (row: Row) => row.id, + startSync: policy === `eager`, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + }, + }, + })) + const client = new DbClient() + let dispose: (() => void) | undefined + try { + dispose = makeCause(shared, client).run() + expect(starts).toBe(policy === `eager` ? 1 : 0) + const direct = client.collection(shared) + await read(direct) + expect(starts).toBe(1) + expect(direct.status).toBe(`ready`) + } finally { + dispose?.() + await client.cleanup() + } + }) + } } - }) + } }) 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..2a314bd1b3 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -40,11 +40,12 @@ function finishSetup(dispose: () => void, initialize: () => void = flushSync) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `infinite-conformance-svelte-${sourceSequence++}`, + id: collectionId ?? `infinite-conformance-svelte-${sourceSequence++}`, getKey: (row) => row.id, initialData: [...initialData], }), @@ -245,6 +246,8 @@ function mountInputControllable( const svelteInfiniteDriver: InfiniteQueryDriver = { name: `svelte`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, gt, makeSource, makeOnDemandSource: (data, delay) => diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 33b93631da..373c904786 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -1,14 +1,8 @@ import { computed, shallowRef, toValue, watchEffect } from 'vue' import { - assertLiveQueryWindowManyResult, - compareLiveQueryWindowDependencies, - createLiveQueryCollection, - createLiveQueryWindowController, + bindLiveQueryWindow, fetchNextLiveQueryWindowPage, - getLiveQueryWindowCollectionWarning, - normalizeLiveQueryWindowPageSize, - resolveLiveQueryWindowInput, - shouldPreserveLiveQueryWindowPageCount, + readLiveQueryWindowPageShape, } from '@tanstack/db' import type { Collection, @@ -18,20 +12,13 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryPersistedStatus, + LiveQueryWindowBinding, NonSingleResult, QueryBuilder, UtilsRecord, } from '@tanstack/db' import type { ComputedRef, MaybeRefOrGetter } from 'vue' -const DEFAULT_GC_TIME_MS = 1 - -type InternalCollection = Collection - -type PreviousController = { - getSnapshot: () => { pages: ReadonlyArray> } -} - type InfiniteQueryOptions = { pageSize?: number /** First result-page label, not a server cursor or remote offset. */ @@ -125,96 +112,23 @@ export function useLiveInfiniteQuery< config: InfiniteQueryOptions, deps: Array> = [], ): UseLiveInfiniteQueryReturn { - if (`getNextPageParam` in config) { - throw new Error( - `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`, - ) - } - let validatedCollection: InternalCollection | null = null - let previousController: PreviousController | null = null - let previousInput: ReturnType< - typeof resolveLiveQueryWindowInput - > | null = null - let previousDependencies: Array | null = null - let previousPageSize: number | null = null - let previousInitialPageParam: number | null = null + let binding: LiveQueryWindowBinding | null = null + // Vue reruns this only when something it reads changed: the input, a + // dependency, the page shape, or a ref the query callback reads. const controller = computed(() => { - const dependencies = deps.map((dependency) => toValue(dependency)) - - const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) - const initialPageParam = config.initialPageParam ?? 0 - const unwrappedInput = - typeof queryFnOrCollection === `function` - ? queryFnOrCollection - : toValue(queryFnOrCollection) - const input = resolveLiveQueryWindowInput(unwrappedInput) - const dependencyComparison = compareLiveQueryWindowDependencies( - previousDependencies, - dependencies, - ) - const dependenciesChanged = dependencyComparison.changed - const dependenciesStructurallyEqual = dependencyComparison.structurallyEqual - const pageShapeChanged = - previousPageSize !== pageSize || - previousInitialPageParam !== initialPageParam - const sameCollection = - input.kind === `collection` && - previousInput?.kind === `collection` && - previousInput.collection === input.collection - const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ - hasPreviousController: previousController !== null, - previousInputKind: previousInput?.kind, - inputKind: input.kind, - sameCollection, - dependenciesChanged, - dependenciesStructurallyEqual, - pageShapeChanged, - }) - const previousPageCount = previousController - ? Math.max(1, previousController.getSnapshot().pages.length) - : 1 - const initialPageCount = canPreservePageCount ? previousPageCount : 1 - - previousInput = input - previousDependencies = [...dependencies] - previousPageSize = pageSize - previousInitialPageParam = initialPageParam - - if (input.kind === `collection`) { - const collection = input.collection - const warning = getLiveQueryWindowCollectionWarning( - collection, - pageSize + 1, - ) - - if (validatedCollection !== collection) { - validatedCollection = collection - if (warning) console.warn(warning) - } - - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, - }) - previousController = currentController - return currentController - } - - const collection = createLiveQueryCollection({ - query: input.query.limit(pageSize + 1).offset(0), - startSync: false, - gcTime: DEFAULT_GC_TIME_MS, - }) - assertLiveQueryWindowManyResult(collection) - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, + const previous = binding + binding = bindLiveQueryWindow(previous, { + ...readLiveQueryWindowPageShape(config), + input: + typeof queryFnOrCollection === `function` + ? queryFnOrCollection + : toValue(queryFnOrCollection), + dependencies: deps.map((dependency) => toValue(dependency)), + recompile: `always`, }) - previousController = currentController - return currentController + if (binding !== previous && binding.warning) console.warn(binding.warning) + return binding.controller }) const snapshot = shallowRef(controller.value.getSnapshot()) diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 807a847e66..8988340281 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -51,10 +51,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-vue-${sourceSeq++}`, + id: collectionId ?? `conformance-vue-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -223,6 +224,8 @@ function mountControllable

( const vueDriver: LiveQueryDriver = { name: `vue`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index a8828f86b1..b6af9407db 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -42,11 +42,12 @@ let sourceSequence = 0 function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `infinite-conformance-vue-${sourceSequence++}`, + id: collectionId ?? `infinite-conformance-vue-${sourceSequence++}`, getKey: (row) => row.id, initialData: [...initialData], }), @@ -253,6 +254,8 @@ function mountInputControllable( const vueInfiniteDriver: InfiniteQueryDriver = { name: `vue`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, gt, makeSource, makeOnDemandSource: (data, delay) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54ebf3f39e..d1cfce5bd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1484,6 +1484,19 @@ importers: specifier: ^12.6.2 version: 12.8.0 + packages/preact-db: + dependencies: + '@tanstack/db': + specifier: workspace:* + version: link:../db + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + preact: + specifier: ^11.0.1 + version: 11.0.1 + packages/query-db-collection: dependencies: '@standard-schema/spec': @@ -10648,6 +10661,14 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} + preact@11.0.1: + resolution: {integrity: sha512-TNNwEq++jYNHy747LziCjPRVgEwnLSOIOAw/oYcjI3Tdnubx2EXtt76I0an8PATvUWtx/DX1kFIg1eFN+O/fkw==} + peerDependencies: + preact-render-to-string: '>=6.7.0' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -22810,6 +22831,8 @@ snapshots: postgres@3.4.8: {} + preact@11.0.1: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 diff --git a/scripts/ci-tests.mjs b/scripts/ci-tests.mjs index 6c43e06889..09613a904f 100644 --- a/scripts/ci-tests.mjs +++ b/scripts/ci-tests.mjs @@ -21,6 +21,7 @@ const groups = { ], frameworks: [ `@tanstack/react-db`, + `@tanstack/preact-db`, `@tanstack/react-router-with-db`, `@tanstack/solid-db`, `@tanstack/vue-db`, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index cbedd2a3c1..7fdf54cf73 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -70,6 +70,13 @@ await generateReferenceDocs({ outputDir: resolve(__dirname, `../docs/framework/react/reference`), exclude: [`packages/db/**/*`], }, + { + name: `preact-db`, + entryPoints: [resolve(__dirname, `../packages/preact-db/src/index.ts`)], + tsconfig: resolve(__dirname, `../packages/preact-db/tsconfig.docs.json`), + outputDir: resolve(__dirname, `../docs/framework/preact/reference`), + exclude: [`packages/db/**/*`], + }, { name: `solid-db`, entryPoints: [resolve(__dirname, `../packages/solid-db/src/index.ts`)], diff --git a/skill_spec.md b/skill_spec.md index 38010e24fd..4bd76695d4 100644 --- a/skill_spec.md +++ b/skill_spec.md @@ -183,6 +183,7 @@ TanStack DB is a reactive client-side data store that provides normalized collec | ------------------------------- | -------------------------------------------------------------------- | | `packages/db` | collection-setup, live-queries, mutations-optimistic, custom-adapter | | `packages/react-db` | framework-integration (React) | +| `packages/preact-db` | framework-integration (Preact) | | `packages/vue-db` | framework-integration (Vue) | | `packages/svelte-db` | framework-integration (Svelte) | | `packages/solid-db` | framework-integration (Solid) | From 0e021ec71406aaf5c224b0b1b0d9ba2fb496988d Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 10:04:16 -0600 Subject: [PATCH 02/17] Check SourceCollectionIdReuseError name in the minified public API Co-authored-by: Isaac --- scripts/test-minified-db.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-minified-db.mjs b/scripts/test-minified-db.mjs index 03cbaad5b8..eee981078d 100644 --- a/scripts/test-minified-db.mjs +++ b/scripts/test-minified-db.mjs @@ -107,6 +107,7 @@ const errorGroups = { QueryOptimizerError: `QueryOptimizerError CannotCombineEmptyExpressionListError`, SetWindowReentrancyError: `SetWindowReentrancyError`, UnhashableQueryIRError: `UnhashableQueryIRError`, + SourceCollectionIdReuseError: `SourceCollectionIdReuseError`, } function checkErrorNames(db) { From fdd6da13ab1dc509e7f1faa4d90d9daa6bd005bb Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 10:26:40 -0600 Subject: [PATCH 03/17] test: declare reach for the deferred-release histories Each React and Svelte release history now asserts that the rejected query materialized the shared descriptor before the direct reader runs, so a history that never enters the deferred state fails instead of passing. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/source-id-reuse-oracle.test.tsx | 46 +++++++++++++------ ...riptor-query-release-oracle.svelte.test.ts | 34 ++++++++++---- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index cdc67ee511..346f859bc4 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`; a new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows; React and Preact drivers rethrow the rejection on later reads because the failed hook is unmounted. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses three causes (source-ID collision, unhashable IR, a descriptor factory that throws) with four hook paths and two direct readers; Svelte crosses the same causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Removing either release point, or routing Svelte's getters around the compiled query, fails the matching cells; `preload()` overrides a deferral, so those cells pass without the release by design. Adapter-specific `queryKey` change semantics stay with each adapter's own tests. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`; a new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows; React and Preact drivers rethrow the rejection on later reads because the failed hook is unmounted. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses three causes (source-ID collision, unhashable IR, a descriptor factory that throws) with four hook paths and two direct readers; Svelte crosses the same causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Removing either release point, or routing Svelte's getters around the compiled query, fails the matching cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Adapter-specific `queryKey` change semantics stay with each adapter's own tests. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/react-db/tests/source-id-reuse-oracle.test.tsx b/packages/react-db/tests/source-id-reuse-oracle.test.tsx index 29ebdc773f..c8f0279750 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -110,6 +110,12 @@ function checkRerender( * The rejected render may defer the shared Collection's sync start, and it * must release that deferral: the source stays unstarted until a direct * reader asks, and that reader then starts it exactly once and reads its row. + * + * Declared reach: every enumerated history must enter the state the law is + * about, a rejected render that materialized the shared descriptor. Each cell + * asserts that the descriptor factory ran once before the reader, so a + * history that rejects before binding the shared source fails instead of + * passing vacuously. */ type ReleaseStep = { current: ReturnType; reject: boolean } @@ -229,22 +235,29 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, const client = new DbClient() const id = `release-shared-${sequence++}` let syncStarts = 0 - const shared = collectionOptions(id, () => ({ + let materialized = 0 + const shared = collectionOptions( id, - getKey: (row: Row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - syncStarts++ - begin() - write({ - type: `insert`, - value: { id: `one`, value: `shared`, rank: 1 }, - }) - commit() - markReady() - }, - }, - })) + () => ( + materialized++, + { + id, + getKey: (row: Row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncStarts++ + begin() + write({ + type: `insert`, + value: { id: `one`, value: `shared`, rank: 1 }, + }) + commit() + markReady() + }, + }, + } + ), + ) const failing = collectionOptions(`${id}-failing`, () => { throw new Error(`descriptor factory failed`) }) @@ -280,6 +293,9 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, , ) expect(root.getByText(`Rejected`)).toBeDefined() + // Declared reach: the rejected render materialized the shared + // descriptor, so it held a deferral the law must release. + expect(materialized).toBe(1) expect(syncStarts).toBe(0) const value = await read(client.collection(shared) as any) diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index 8f53b42709..8cb95872dc 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -28,6 +28,10 @@ * error does not rerun that same Svelte effect when its query changes; * recovery in place is not promised. Asynchronous source failures remain * outside this model. + * + * Declared reach: each enumerated release history asserts that the rejected + * query ran the shared descriptor's factory once before the reader. A history + * that rejects before it binds the shared source therefore fails. */ import { describe, expect, it } from 'vitest' import { @@ -283,21 +287,31 @@ describe(`Svelte descriptor source release`, () => { it(`after ${causeName}, a ${policy} source follows its policy and ${readerName} starts it`, async () => { const id = `svelte-release-${sequence++}` let starts = 0 - const shared = collectionOptions(id, () => ({ + let materialized = 0 + const shared = collectionOptions( id, - getKey: (row: Row) => row.id, - startSync: policy === `eager`, - sync: { - sync: ({ markReady }) => { - starts++ - markReady() - }, - }, - })) + () => ( + materialized++, + { + id, + getKey: (row: Row) => row.id, + startSync: policy === `eager`, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + }, + }, + } + ), + ) const client = new DbClient() let dispose: (() => void) | undefined try { dispose = makeCause(shared, client).run() + // Declared reach: the rejected query materialized the shared + // descriptor, so it held a deferral the law must release. + expect(materialized).toBe(1) expect(starts).toBe(policy === `eager` ? 1 : 0) const direct = client.collection(shared) await read(direct) From 64fb11fead97dd5d467161744ad2150f7fa5e1e5 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 10:37:58 -0600 Subject: [PATCH 04/17] fix: release deferred sync starts when a live query rejects at compile A query that the live-query compiler or the window binding rejected after preparation kept its deferred DbClient sync starts, so another hook or startSyncImmediate() could not start the shared source. React useLiveQuery, React useLiveInfiniteQuery, and DbClient-backed Svelte useLiveQuery now release them at that compile point. The release grammars add a compile rejection cause and, for infinite hooks, a single-result query cause. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/live-query-adapter.ts | 1 + packages/react-db/src/useLiveInfiniteQuery.ts | 36 +++++++++------- packages/react-db/src/useLiveQuery.ts | 24 +++++++---- .../tests/source-id-reuse-oracle.test.tsx | 41 ++++++++++++++++++- packages/svelte-db/src/useLiveQuery.svelte.ts | 22 +++++----- ...riptor-query-release-oracle.svelte.test.ts | 24 ++++++++++- 7 files changed, 112 insertions(+), 38 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 346f859bc4..8d5ba5bf1d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`; a new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows; React and Preact drivers rethrow the rejection on later reads because the failed hook is unmounted. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses three causes (source-ID collision, unhashable IR, a descriptor factory that throws) with four hook paths and two direct readers; Svelte crosses the same causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Removing either release point, or routing Svelte's getters around the compiled query, fails the matching cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Adapter-specific `queryKey` change semantics stay with each adapter's own tests. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`; a new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows; React and Preact drivers rethrow the rejection on later reads because the failed hook is unmounted. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses five causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, and a single-result query in an infinite hook) with four hook paths and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Removing either release point, or routing Svelte's getters around the compiled query, fails the matching cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Adapter-specific `queryKey` change semantics stay with each adapter's own tests. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index 69a26c65d5..c9077fd309 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -30,6 +30,7 @@ export { getPreparedLiveQueryIdentity, getPreparedLiveQuerySources, prepareLiveQueryValue, + rejectLiveQueryInput, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from './live-query-options.js' diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 66223831cd..e9216b9c36 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -8,6 +8,7 @@ import { identifyLiveQuery, isCollection, readLiveQueryWindowPageShape, + rejectLiveQueryInput, resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' @@ -227,20 +228,27 @@ export function useLiveInfiniteQuery( } const rendered = renderedRef.current - const binding = bindLiveQueryWindow( - committed?.binding ?? null, - { - ...pageShape, - input: - typeof queryFnOrCollection === `function` - ? compileQuery - : queryFnOrCollection, - dependencies, - scope: dbClient, - recompile: usesLegacyDeps ? `dependencies` : `identity`, - }, - rendered?.binding, - ) + let binding: LiveQueryWindowBinding + try { + binding = bindLiveQueryWindow( + committed?.binding ?? null, + { + ...pageShape, + input: + typeof queryFnOrCollection === `function` + ? compileQuery + : queryFnOrCollection, + dependencies, + scope: dbClient, + recompile: usesLegacyDeps ? `dependencies` : `identity`, + }, + rendered?.binding, + ) + } catch (error) { + // A window query the binding rejects, such as a single-result or + // uncompilable query, keeps no sync-start deferral. + rejectLiveQueryInput(deferredCollections, error) + } let currentRenderState = committed if (binding !== committed?.binding) { diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index f030ee1d4b..5abbe093fa 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -12,6 +12,7 @@ import { identifyLiveQuery, isCollection, prepareLiveQueryValue, + rejectLiveQueryInput, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from '@tanstack/db' @@ -930,15 +931,20 @@ function useLiveQueryImpl( if (!dbClient) { getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) } - instance.collection = resolveLiveQueryValue(preparedQueryValue, { - gcTime: forSuspense - ? DEFAULT_SUSPENSE_GC_TIME_MS - : DEFAULT_GC_TIME_MS, - // Hydration and Suspense key the live-query Collection by identity. - pool: !forSuspense && !dbClient, - // Without a DbClient, Suspense starts it through a preload below. - startSync: !forSuspense || dbClient !== undefined, - }) as SuspenseCollection | null + try { + instance.collection = resolveLiveQueryValue(preparedQueryValue, { + gcTime: forSuspense + ? DEFAULT_SUSPENSE_GC_TIME_MS + : DEFAULT_GC_TIME_MS, + // Hydration and Suspense key the live-query Collection by identity. + pool: !forSuspense && !dbClient, + // Without a DbClient, Suspense starts it through a preload below. + startSync: !forSuspense || dbClient !== undefined, + }) as SuspenseCollection | null + } catch (error) { + // A query the live query rejects keeps no sync-start deferral. + rejectLiveQueryInput(instance.deferredCollections, error) + } if (suspenseCollections && suspenseKey && instance.collection) { const collection = instance.collection const removeCleanupListener = collection.on(`status:cleaned-up`, () => 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 c8f0279750..6439ff55cf 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -19,7 +19,9 @@ * shared client descriptor, so a later direct reader can start that Collection. * The release history grammar crosses every rejection cause (a source ID * collision, a query without a stable IR identity, a descriptor factory that - * throws while the query binds its sources), every hook path (live and + * throws while the query binds its sources, a query the live query rejects + * when it compiles, and a single-result query in an infinite hook, which + * pages many rows), every hook path (live and * infinite, with derived identity or a dependency list), and both direct * readers (another hook over the Collection, and `startSyncImmediate()`). The * release must also hold when earlier deferred sync starts throw: each pending @@ -127,6 +129,8 @@ const releaseCauses: Record< ) => { rejected: (step: ReleaseStep) => ReleaseStep query: (q: any, step: ReleaseStep, ordered: boolean) => any + /** Whether the cause rejects in an unordered or ordered (infinite) hook. */ + rejectsIn?: (ordered: boolean) => boolean } > = { 'a source ID collision': (shared) => ({ @@ -178,6 +182,39 @@ const releaseCauses: Record< ordered, ), }), + 'a query the live query rejects when it compiles': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + // A root scalar select is valid IR that compilation refuses. + query: (q, step, ordered) => + step.reject + ? orderIf( + q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ), + ordered, + ).select(({ settings }: any) => settings.value) + : orderIf(q.from({ settings: step.current }), ordered), + }), + 'a single-result window query': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => { + const query = orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ) + return step.reject ? query.findOne() : query + }, + // A live query may read one row; an infinite query must page many. + rejectsIn: (ordered) => ordered, + }), } function orderIf(query: any, ordered: boolean) { @@ -230,6 +267,8 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, let sequence = 0 for (const [causeName, makeCause] of Object.entries(releaseCauses)) { for (const [hookName, hook] of Object.entries(releaseHooks)) { + const probe = makeCause(undefined, undefined) + if (probe.rejectsIn && !probe.rejectsIn(hook.ordered)) continue for (const [readerName, read] of Object.entries(releaseReaders)) { it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source`, async () => { const client = new DbClient() diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 3700dee9fe..29d4df984b 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -3,7 +3,6 @@ import { untrack } from 'svelte' // eslint-disable-next-line import/no-duplicates -- See https://github.com/un-ts/eslint-plugin-import-x/issues/308 import { SvelteMap } from 'svelte/reactivity' import { - BaseQueryBuilder, UnhashableQueryIRError, createLiveQueryObserver, createLiveQuerySourceIdClaims, @@ -14,6 +13,7 @@ import { isCollection, isSingleResultCollection, prepareLiveQueryValue, + rejectLiveQueryInput, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from '@tanstack/db' @@ -453,17 +453,17 @@ export function useLiveQuery( collection = null } else if (isCollection(preparedValue)) { collection = preparedValue - } else if (preparedValue instanceof BaseQueryBuilder) { - // Hydration keys the live-query Collection by identity. - collection = resolveLiveQueryValue(preparedValue, { - gcTime: DEFAULT_GC_TIME_MS, - pool: !dbClient, - }) } else { - collection = resolveLiveQueryValue(preparedValue, { - gcTime: DEFAULT_GC_TIME_MS, - pool: !dbClient, - }) + try { + // Hydration keys the live-query Collection by identity. + collection = resolveLiveQueryValue(preparedValue, { + gcTime: DEFAULT_GC_TIME_MS, + pool: !dbClient, + }) + } catch (error) { + // A query the live query rejects keeps no sync-start deferral. + rejectLiveQueryInput(deferredCollections, error) + } } return { diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index 8cb95872dc..e344ce50b1 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -11,8 +11,9 @@ * must still become one and the failed effect must release its subscription. * The release grammar crosses three rejection causes for a client-backed * query (a descriptor factory that throws during preparation, a query without - * a stable IR identity, and a different source object under a Collection ID - * the mounted hook already read), two source start policies, and three direct + * a stable IR identity, a query the live query rejects when it compiles, and + * a different source object under a Collection ID the mounted hook already + * read), two source start policies, and three direct * readers (`preload()`, `startSyncImmediate()`, and another hook). After the * rejection, an eager (`startSync: true`) source has started once and a lazy * source has not started; after the reader, each has started exactly once @@ -39,6 +40,7 @@ import { Query, SourceCollectionIdReuseError, UnhashableQueryIRError, + UnsupportedRootScalarSelectError, collectionOptions, createCollection, eq, @@ -220,6 +222,24 @@ describe(`Svelte descriptor source release`, () => { return () => dispose?.() }, }), + 'a query the live query rejects when it compiles': (shared, client) => ({ + rejected: `UnsupportedRootScalarSelectError`, + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ + client, + // A root scalar select is valid IR that compilation refuses. + query: new Query() + .from({ shared }) + .select(({ shared: row }: any) => row.id), + }) + }) + }).toThrow(UnsupportedRootScalarSelectError) + return () => dispose?.() + }, + }), 'a reused source Collection ID': (shared, client) => ({ rejected: `SourceCollectionIdReuseError`, run: () => { From d6313924a5c5a47d089140c9090a848ebd27e60d Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:02:44 -0600 Subject: [PATCH 05/17] fix: release deferred sync starts at each hook's render boundary Address the high code review of this PR (R1-R10) on the oracle-first path. - Release law: each React hook (useLiveQuery, useLiveSuspenseQuery, useLiveInfiniteQuery) and Svelte's compiled query release deferred sync starts once, at the render boundary, for any throw after preparation. This replaces five per-call releases and closes the Suspense SSR gap (R1, R6). prepareLiveQueryValue and identifyLiveQuery no longer release. - Svelte without a DbClient treats `queryKey: []` as no key (R2). - React conformance runs both identity paths: derived and dependency list (R4). - The infinite suite adds `queryKey: []`, recompile to an unhashable query, and one query over two same-ID objects; React and Preact infinite share getLiveQueryKey (R5). - Source-ID claim memory is a 32-history enumerated grammar with a pairwise model and declared reach (R7). - bindLiveQueryWindow snapshots dependencies, so a queryKey changed in place recompiles, as on main (R9). React reuses the deps identity at compile (R10). - Contract prose states that no current driver can fail the no-rows-after- rejection check (R3). Evidence: - Law: a rejected query input releases every sync start it deferred, so a later direct reader can start the source (React source-ID reuse oracle). Identity: comparing hooks reject unhashable queries and same-ID swaps; claims outlive intervening and disabled queries (contract.ts, maintainer decisions 2026-10-22/23). - Reach: every release history asserts the rejected query materialized the shared descriptor (71 React, 26 Svelte). Claim grammar declares and reaches swap-after-intervening-query, swap-after-disabled-step, and revisit-after-intervening-query. - RED (parent fdd6da13a/64fb11fea): Suspense SSR cause fails 2 React cells (reader cannot start the source); Svelte `queryKey: []` reactive case fails; in-place queryKey mutation keeps the old pages. - GREEN: react-db 565, svelte-db 164, preact-db 97, vue-db 140, solid-db 103, angular-db 77, db 13790. - Mutant: removing any one render boundary fails exactly that hook's cells (33, 2, 20 React; 20 Svelte). Derived path without claims fails source-id-reuse. A queryKey reader that keeps `[]` fails both infinite hooks. Claims that keep only the latest query, or reset at a disabled step, fail the claim grammar for React and Preact. - Limits: `preload()` overrides a deferral, so those cells pass without the release. DbClient-backed mount and recompile histories remain fixed receiving witnesses. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/live-query-adapter.ts | 36 +- packages/db/src/live-query-options.ts | 33 +- packages/db/src/live-query-window-binding.ts | 3 +- packages/db/tests/conformance/contract.ts | 7 +- .../db/tests/conformance/infinite-contract.ts | 1 + .../conformance/infinite-suite-oracle.ts | 92 +++- packages/db/tests/conformance/suite.ts | 160 ++++-- .../preact-db/src/useLiveInfiniteQuery.ts | 6 +- .../tests/infinite-query-conformance.test.ts | 2 + packages/react-db/src/live-query-internals.ts | 3 + packages/react-db/src/useLiveInfiniteQuery.ts | 91 ++-- packages/react-db/src/useLiveQuery.ts | 501 +++++++++--------- packages/react-db/src/useLiveSuspenseQuery.ts | 238 +++++---- packages/react-db/tests/conformance.test.tsx | 62 ++- .../tests/infinite-query-conformance.test.tsx | 66 ++- .../tests/source-id-reuse-oracle.test.tsx | 96 +++- .../tests/useLiveInfiniteQuery.test.tsx | 36 ++ packages/svelte-db/src/useLiveQuery.svelte.ts | 124 ++--- .../infinite-query-conformance.svelte.test.ts | 2 + .../tests/infinite-query-conformance.test.ts | 2 + 21 files changed, 922 insertions(+), 641 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 8d5ba5bf1d..1cd6b32e15 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`; a new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows; React and Preact drivers rethrow the rejection on later reads because the failed hook is unmounted. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses five causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, and a single-result query in an infinite hook) with four hook paths and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Removing either release point, or routing Svelte's getters around the compiled query, fails the matching cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Adapter-specific `queryKey` change semantics stay with each adapter's own tests. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Claims that keep only the latest query, or reset at a disabled step, fail it. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check: React and Preact drivers rethrow on later reads because the failed hook is unmounted, and reactive drivers do not reject. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index c9077fd309..b80eb6b69a 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -3,13 +3,9 @@ import { isCollection } from './live-query-primitives.js' import { getPreparedLiveQueryIdentity, getPreparedLiveQuerySources, - rejectLiveQueryInput, } from './live-query-options.js' import type { DbClient } from './client.js' -import type { - DeferredLiveQueryCollections, - LiveQueryKey, -} from './live-query-options.js' +import type { LiveQueryKey } from './live-query-options.js' /** * The adapter layer: what the first-party framework adapters @@ -126,8 +122,6 @@ export interface LiveQueryIdentityScope { claims?: LiveQuerySourceIdClaims client: DbClient | undefined bindingName: string - /** Sync starts the query's preparation deferred. */ - deferredCollections?: DeferredLiveQueryCollections /** Report a descriptor source that no client bound. */ onUnboundDescriptor?: (alias: string) => never } @@ -135,9 +129,9 @@ export interface LiveQueryIdentityScope { /** * Identify a prepared query for a binding that compares query identity. The * identity throws `UnhashableQueryIRError` for IR without a stable identity, - * then the query's sources claim their Collection IDs. Either rejection - * releases the sync starts the preparation deferred, so a later reader, such - * as another hook over the same Collection, can still start those sources. + * then the query's sources claim their Collection IDs. The binding's render + * boundary releases the sync starts a rejected render deferred; see + * `rejectLiveQueryInput`. */ export function identifyLiveQuery( preparedValue: unknown, @@ -148,18 +142,14 @@ export function identifyLiveQuery( preparedValue: unknown, ) => TIdentity, ): TIdentity { - try { - const identity = identify(preparedValue) - if (scope.claims) { - claimLiveQuerySourceIds( - scope.claims, - getPreparedLiveQuerySources(preparedValue, scope.onUnboundDescriptor), - scope.client, - scope.bindingName, - ) - } - return identity - } catch (error) { - return rejectLiveQueryInput(scope.deferredCollections, error) + const identity = identify(preparedValue) + if (scope.claims) { + claimLiveQuerySourceIds( + scope.claims, + getPreparedLiveQuerySources(preparedValue, scope.onUnboundDescriptor), + scope.client, + scope.bindingName, + ) } + return identity } diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 86edc8044a..d335438703 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -34,8 +34,11 @@ export type DeferredLiveQueryCollections = Set< /** * Release the sync starts a rejected query input deferred, then rethrow its - * rejection. As for a `DbClient` preload, the rejection wins over a source - * whose resumed start fails; that source records its own failure. + * rejection. A framework binding calls this at its render boundary, so a + * render that throws anywhere after preparation leaves no source deferred and + * a later reader can start it. As for a `DbClient` preload, the rejection wins + * over a source whose resumed start fails; that source records its own + * failure. */ export function rejectLiveQueryInput( collections: DeferredLiveQueryCollections | undefined, @@ -103,31 +106,13 @@ function createCollectionResolver( } } -/** - * Bind a query input's descriptor sources to `dbClient` and invoke its query - * callback. Sources materialized for a render defer their sync start into - * `deferredCollections`; a preparation that throws releases them, so a later - * reader can still start those sources. - */ export function prepareLiveQueryValue( value: unknown, dbClient: DbClient | undefined, deferredCollections?: DeferredLiveQueryCollections, -): unknown { - try { - return prepareValue(value, dbClient, deferredCollections) - } catch (error) { - return rejectLiveQueryInput(deferredCollections, error) - } -} - -function prepareValue( - value: unknown, - dbClient: DbClient | undefined, - deferredCollections?: DeferredLiveQueryCollections, ): unknown { if (typeof value === `function`) { - return prepareValue( + return prepareLiveQueryValue( value(createInitialQueryBuilder()), dbClient, deferredCollections, @@ -154,7 +139,11 @@ function prepareValue( ...config } = value as PreparedLiveQueryConfigInput - const preparedQuery = prepareValue(query, dbClient, deferredCollections) + const preparedQuery = prepareLiveQueryValue( + query, + dbClient, + deferredCollections, + ) if (preparedQuery === undefined || preparedQuery === null) { return preparedQuery diff --git a/packages/db/src/live-query-window-binding.ts b/packages/db/src/live-query-window-binding.ts index 25a4f646b8..8964dc3767 100644 --- a/packages/db/src/live-query-window-binding.ts +++ b/packages/db/src/live-query-window-binding.ts @@ -133,7 +133,8 @@ export function bindLiveQueryWindow( ? `collection` : `query` : undefined - const dependencies = supplied ? undefined : (update.dependencies ?? []) + // A snapshot: a caller may change its dependency array in place. + const dependencies = supplied ? undefined : [...(update.dependencies ?? [])] const change = compareUpdate(previous, update, dependencies) const needsNewCollection = diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 7351831f45..253705fb93 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -49,7 +49,12 @@ * the next `current()` read. After a rejection, no later read exposes rows: * each `current()` throws or holds none, so a framework whose memo throws once * cannot fall back to the previous query's rows. Each driver releases any - * native scope that a rejected mount acquired. + * native scope that a rejected mount acquired. In the current drivers this + * check cannot fail: React and Preact unmount a hook whose render throws, so + * their drivers rethrow on later reads, and reactive drivers do not reject + * here. It guards a future driver whose binding stays mounted. The one such + * binding today, a `DbClient`-backed Svelte query, has its own witness in the + * Svelte descriptor release oracle, which reads each field after a rejection. * * All current scenarios must pass. A future known bug needs an independently * approved exact failure signature; a whole-test waiver could hide another bug. diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index d6a5130fb3..a32556f6e6 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -111,6 +111,7 @@ export interface InfiniteQueryDriver { /** Whether the binding compares query identity; see `contract.ts`. */ comparesQueryIdentity: boolean gt: (a: any, b: any) => any + eq: (a: any, b: any) => any /** Pass `collectionId` to create another source object under an existing ID. */ makeSource: ( initialData: ReadonlyArray, diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 54e1da2437..83b3a7019e 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -24,6 +24,7 @@ import type { InfiniteQueryDriver, InfiniteQueryHandle, } from './infinite-contract' +import type { QueryBuild } from './contract' interface InfiniteRow { id: string @@ -1687,10 +1688,16 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { async () => { const source = driver.makeSource(rows(8)) if (driver.comparesQueryIdentity) { - const error = await captureError(() => - driver.mount(functionalRanks(source), { pageSize: 3 }), - ) - expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + // An empty key is absent, as it is for a DbClient preload. + for (const config of [ + { pageSize: 3 }, + { pageSize: 3, queryKey: [] }, + ]) { + const error = await captureError(() => + driver.mount(functionalRanks(source), config), + ) + expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + } } const handle = driver.mount( functionalRanks(source), @@ -1758,8 +1765,83 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { }, ) + scenario( + `unhashable-recompile`, + `recompiling to a query without a stable IR identity follows the binding's identity policy`, + async () => { + const source = driver.makeSource(rows(8)) + // `null` selects the structured query; a number, the functional one + // that keeps ranks at or below it. + const handle = driver.mountControllable( + (q, atMost: number | null) => { + const query = q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + return atMost === null + ? query + : query.fn.where(({ items }: any) => items.rank <= atMost) + }, + null as number | null, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(0, 3), 3) + if (driver.comparesQueryIdentity) { + const error = await captureRejection(async () => { + handle.setParamSync(4) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + return + } + // A changed captured value recompiles, though no identity names it. + handle.setParamSync(4) + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(4, 7), 3) + handle.setParamSync(2) + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(6, 8), 3) + }, + ) + + scenario( + `source-id-reuse-in-one-query`, + `rejects or pages one query that reads two sources under one Collection ID`, + async () => { + const first = driver.makeSource(rows(4)) + const reused = driver.makeSource(rows(2), first.collection.id) + const build: QueryBuild = (q) => + q + .from({ items: first.collection }) + .join( + { other: reused.collection }, + ({ items, other }: any) => driver.eq(items.id, other.id), + `inner`, + ) + .orderBy(({ items }: any) => items.rank, `desc`) + .select(({ items }: any) => ({ ...items })) + if (driver.comparesQueryIdentity) { + const error = await captureError(() => + driver.mount(build, { pageSize: 3 }), + ) + expect(error).toMatchObject({ name: `SourceCollectionIdReuseError` }) + return + } + // The inner join matches only the ids both objects hold, so a binding + // that read one object for both sides would page all four rows. + const handle = driver.mount(build, { pageSize: 3 }) + await handle.flush() + expect(handle.current().data.map((row: any) => row.id)).toEqual( + rows(4) + .filter((row) => row.id === `1` || row.id === `2`) + .map((row) => row.id), + ) + }, + ) + it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(41) + expect(registry.size).toBe(43) }) }) } diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 2e9875d466..bb080657bf 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -41,14 +41,19 @@ * without `queryKey` (an empty key counts as none) at mount and at * recompilation and accepts it with a key; * a reactive binding runs it and recompiles when a captured value changes. - * The source-ID history distinguishes a remembered claim from a check against - * only the previous query: A(x) -> C(y) -> A(x) is legal, and a later - * C(y) -> B(x) rejects in a comparing binding and reads B in a reactive one. + * The source-ID grammar enumerates every three-step history over A(x), B(x) + * (another object under A's ID), C(y), and a disabled step, judged by a + * pairwise model. It distinguishes a remembered claim from a check against + * only the previous query: A(x) -> C(y) -> B(x) and A(x) -> disabled -> B(x) + * reject in a comparing binding and read B in a reactive one, while + * A(x) -> C(y) -> A(x) is legal everywhere. It declares reach for each of + * those states. * A new binding reads B after the first unmounts. Two same-ID objects in one * query follow the same split: rejected, or each read. */ import { describe, expect, it } from 'vitest' import { waitForPersistedInitialRender } from '@tanstack/db' +import { createOracleReach } from '../oracle-reach' import { ScenarioLifetime } from './scenario-lifetime' import { ScenarioSources } from './scenario-sources' import { scenarioRegistry } from './registration' @@ -103,7 +108,9 @@ async function expectRejection( /** * After a rejection, every read throws or holds no rows. Two reads catch a - * memo that throws once and then serves its previous value. + * memo that throws once and then serves its previous value. No current driver + * can fail this check (see `contract.ts`); it guards a driver whose binding + * stays mounted after a rejection. */ function expectNoRowsAfterRejection(h: LiveQueryHandle): void { for (let read = 0; read < 2; read++) { @@ -219,6 +226,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { key: string, name: string, fn: () => Promise | void, + // A generated grammar runs many histories, each through the driver's cut. + timeoutMs?: number, ) => { registry.register(key) const label = `[${key}] ${name}` @@ -240,7 +249,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { sources = null } } - it(label, run) + it(label, run, timeoutMs) } describe(`live-query conformance :: ${driver.name}`, () => { @@ -1182,6 +1191,13 @@ export function runSuite(rawDriver: LiveQueryDriver) { h.unmount() } } + if (!comparesIdentity) { + // An empty key counts as none, so it changes nothing here either. + const h = driver.mountConfig(build, { queryKey: [] }) + await h.flush() + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + h.unmount() + } if (comparesIdentity) { // An empty key is absent, as it is for a DbClient preload, so the // query still needs a derived identity. @@ -1237,44 +1253,108 @@ export function runSuite(rawDriver: LiveQueryDriver) { `source-id-reuse`, `a different source under a Collection ID the binding read is rejected or read as the new source`, async () => { - const first = driver.makeSource(SEED) - const other = driver.makeSource([ - { id: `9`, name: `Other`, age: 40, team: `c` }, - ]) + // Claim memory grammar. A history mounts one controllable binding on + // A(x) or C(y), then applies two actions. An action selects A(x), B(x) + // (another object under A's ID), C(y), or D, which disables the query. + // The model judges each step from the history alone, pairwise and + // independent of production: in a comparing binding, a step rejects + // when an earlier step of the same binding read another object under + // its ID, and the rejection ends the history because the failed hook + // unmounts. A reactive binding never rejects and shows the rows of + // the source the step selects. + const sourcesByPick = { + A: driver.makeSource(SEED), + C: driver.makeSource([ + { id: `9`, name: `Other`, age: 40, team: `c` }, + ]), + } const reused = driver.makeSource( [{ id: `8`, name: `Reused`, age: 50, team: `d` }], - first.collection.id, + sourcesByPick.A.collection.id, ) - expect(reused.collection.id).toBe(first.collection.id) - expect(reused.collection).not.toBe(first.collection) - const h = driver.mountControllable<{ collection: any }>( - (q, source) => - q - .from({ items: source.collection }) - .select(({ items }: any) => ({ id: items.id })), - first, - ) - await h.flush() - expectUnorderedRows(h.current().data, idsOf(SEED)) - // A fresh ID and a revisit of the claimed object are legal. - await h.setParam(other) - expectUnorderedRows(h.current().data, [{ id: `9` }]) - await h.setParam(first) - expectUnorderedRows(h.current().data, idsOf(SEED)) - // The claim outlives the query that made it: going through another - // ID first must not let a different object reuse the first ID. - await h.setParam(other) - if (comparesIdentity) { - await expectRejection(`SourceCollectionIdReuseError`, async () => { - await h.setParam(reused) - return h.current() - }) - expectNoRowsAfterRejection(h) - } else { - await h.setParam(reused) - expectUnorderedRows(h.current().data, [{ id: `8` }]) + expect(reused.collection).not.toBe(sourcesByPick.A.collection) + type Pick = `A` | `B` | `C` | `D` + const objectOf = (pick: Exclude) => + pick === `B` ? reused : sourcesByPick[pick] + const rowsOf: Record, Array<{ id: string }>> = { + A: idsOf(SEED), + B: [{ id: `8` }], + C: [{ id: `9` }], } - h.unmount() + const collides = (history: ReadonlyArray, next: Pick) => + next !== `D` && + history.some( + (prior) => + prior !== `D` && + prior !== next && + objectOf(prior).collection.id === objectOf(next).collection.id, + ) + // Declared reach: the states where a claim must outlive the query + // that made it. + const reach = createOracleReach(`source-id claim memory`, [ + `swap after an intervening query`, + `swap after a disabled step`, + `revisit after an intervening query`, + ]) + const picks: ReadonlyArray = [`A`, `B`, `C`, `D`] + for (const first of [`A`, `C`] as const) { + for (const second of picks) { + for (const third of picks) { + reach.beginHistory() + const history: Array = [first] + const h = driver.mountControllable( + (q, pick) => + pick === `D` + ? null + : q + .from({ items: objectOf(pick).collection }) + .select(({ items }: any) => ({ id: items.id })), + first, + ) + await h.flush() + expectUnorderedRows(h.current().data, rowsOf[first]) + for (const next of [second, third]) { + const previous = history[history.length - 1]! + if (collides(history, next)) { + reach.reach( + previous === `D` + ? `swap after a disabled step` + : `swap after an intervening query`, + ) + } else if ( + next !== `D` && + next !== previous && + history.includes(next) + ) { + reach.reach(`revisit after an intervening query`) + } + if (comparesIdentity && collides(history, next)) { + await expectRejection( + `SourceCollectionIdReuseError`, + async () => { + await h.setParam(next) + return h.current() + }, + ) + expectNoRowsAfterRejection(h) + break + } + await h.setParam(next) + history.push(next) + if (next === `D`) { + expectDisabledResult( + h.current(), + driver.disabledRepresentation, + ) + } else { + expectUnorderedRows(h.current().data, rowsOf[next]) + } + } + h.unmount() + } + } + } + reach.assertReached() // A new binding starts with no claims. const fresh = driver.mount((q) => @@ -1286,6 +1366,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { expectUnorderedRows(fresh.current().data, [{ id: `8` }]) fresh.unmount() }, + // 32 histories of three steps each. + 30_000, ) scenario( diff --git a/packages/preact-db/src/useLiveInfiniteQuery.ts b/packages/preact-db/src/useLiveInfiniteQuery.ts index c73e9b93f5..2c02a4f21f 100644 --- a/packages/preact-db/src/useLiveInfiniteQuery.ts +++ b/packages/preact-db/src/useLiveInfiniteQuery.ts @@ -4,6 +4,7 @@ import { createLiveQuerySourceIdClaims, fetchNextLiveQueryWindowPage, getLiveQueryHash, + getLiveQueryKey, identifyLiveQuery, prepareLiveQueryValue, readLiveQueryWindowPageShape, @@ -109,8 +110,9 @@ export function useLiveInfiniteQuery( let preparedValue: unknown let identity: string | undefined if (typeof queryFnOrCollection === `function`) { - if (config.queryKey?.length) { - identity = getLiveQueryHash(undefined, config.queryKey) + const queryKey = getLiveQueryKey(config) + if (queryKey) { + identity = getLiveQueryHash(undefined, queryKey) } else { preparedValue = prepareLiveQueryValue(queryFnOrCollection, undefined) identity = identifyLiveQuery( diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts index 65399cce8d..55dcea7242 100644 --- a/packages/preact-db/tests/infinite-query-conformance.test.ts +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -23,6 +23,7 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { mockSyncCollectionOptions } from '../../db/tests/utils' @@ -230,6 +231,7 @@ const preactInfiniteDriver: InfiniteQueryDriver = { // Each render re-runs the query input and compares its identity. comparesQueryIdentity: true, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), diff --git a/packages/react-db/src/live-query-internals.ts b/packages/react-db/src/live-query-internals.ts index b68cdf1abc..cfb1bebd14 100644 --- a/packages/react-db/src/live-query-internals.ts +++ b/packages/react-db/src/live-query-internals.ts @@ -1,5 +1,6 @@ import type { DbClient, + DeferredLiveQueryCollections, LiveQueryObserver, UnhashableQueryIRError, } from '@tanstack/db' @@ -11,6 +12,8 @@ export type LiveQueryResultInfo = { queryHash: string | undefined identityError: UnhashableQueryIRError | undefined observer: LiveQueryObserver + /** Sync starts this render deferred, released if the render throws. */ + deferredCollections: DeferredLiveQueryCollections } type ResultWithInfo = { diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index e9216b9c36..20fc695deb 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -5,6 +5,7 @@ import { bindLiveQueryWindow, createLiveQuerySourceIdClaims, fetchNextLiveQueryWindowPage, + getLiveQueryKey, identifyLiveQuery, isCollection, readLiveQueryWindowPageShape, @@ -175,8 +176,7 @@ export function useLiveInfiniteQuery( deferredCollections, ) - // An empty key is absent, as it is for a DbClient preload. - const queryKey = config.queryKey?.length ? config.queryKey : undefined + const queryKey = getLiveQueryKey(config) const usesLegacyDeps = !inputIsCollection && queryKey === undefined && deps !== undefined let preparedQueryValue: unknown | typeof unpreparedQueryValue = @@ -184,52 +184,55 @@ export function useLiveInfiniteQuery( let dependencies: ReadonlyArray | undefined let derivedQuery: unknown = undefined - if (!inputIsCollection) { - if (queryKey !== undefined) { - dependencies = queryKey - } else if (usesLegacyDeps) { - dependencies = deps - warnDeprecatedDepsArray(`useLiveInfiniteQuery`) - } else if ( - committed !== null && - committed.derivedQuery === queryFnOrCollection && - committed.binding.scope === dbClient - ) { - // The same callback in the same client scope keeps its identity. - derivedQuery = queryFnOrCollection - dependencies = committed.binding.dependencies - } else { - const preparation = prepareDerivedQuery( - queryFnOrCollection, - derivedIdentityProfilerRef.current, - identityScope(), - ) - preparedQueryValue = preparation.value - derivedQuery = queryFnOrCollection - dependencies = preparation.identityDeps + const rendered = renderedRef.current + let binding: LiveQueryWindowBinding + // The render boundary: a render that throws anywhere after preparation, + // such as an unhashable, single-result, or uncompilable query, never + // commits, so it releases the sync starts it deferred here. + try { + if (!inputIsCollection) { + if (queryKey !== undefined) { + dependencies = queryKey + } else if (usesLegacyDeps) { + dependencies = deps + warnDeprecatedDepsArray(`useLiveInfiniteQuery`) + } else if ( + committed !== null && + committed.derivedQuery === queryFnOrCollection && + committed.binding.scope === dbClient + ) { + // The same callback in the same client scope keeps its identity. + derivedQuery = queryFnOrCollection + dependencies = committed.binding.dependencies + } else { + const preparation = prepareDerivedQuery( + queryFnOrCollection, + derivedIdentityProfilerRef.current, + identityScope(), + ) + preparedQueryValue = preparation.value + derivedQuery = queryFnOrCollection + dependencies = preparation.identityDeps + } } - } - // The binding calls this only when it compiles a new window collection. - const compileQuery = () => { - if (preparedQueryValue === unpreparedQueryValue) { - preparedQueryValue = prepareQueryValue( - queryFnOrCollection, - dbClient, - deferredCollections, - ) - if (usesLegacyDeps) { - // A dependency list decides reuse, so check the query when it - // compiles: it needs a stable identity and keeps its sources' IDs. - identifyLiveQuery(preparedQueryValue, identityScope()) + // The binding calls this only when it compiles a new window collection. + const compileQuery = () => { + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + queryFnOrCollection, + dbClient, + deferredCollections, + ) + if (usesLegacyDeps) { + // A dependency list decides reuse, so check the query when it + // compiles: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery(preparedQueryValue, identityScope()) + } } + return preparedQueryValue } - return preparedQueryValue - } - const rendered = renderedRef.current - let binding: LiveQueryWindowBinding - try { binding = bindLiveQueryWindow( committed?.binding ?? null, { @@ -245,8 +248,6 @@ export function useLiveInfiniteQuery( rendered?.binding, ) } catch (error) { - // A window query the binding rejects, such as a single-result or - // uncompilable query, keeps no sync-start deferral. rejectLiveQueryInput(deferredCollections, error) } diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 5abbe093fa..8df40ceaa0 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -7,6 +7,7 @@ import { createLiveQuerySourceIdClaims, deepEquals, getLiveQueryKey, + getPreparedLiveQueryIdentity, getSourceObjectToken, getStableValueHash, identifyLiveQuery, @@ -752,186 +753,186 @@ function useLiveQueryImpl( const resumeDeferredCollections = () => resumeDeferredLiveQueryCollections(instance.deferredCollections) - const queryKey = !inputIsCollection - ? getLiveQueryKey(configOrQueryOrCollection) - : undefined - let preparedQueryValue: unknown | typeof unpreparedQueryValue = - unpreparedQueryValue - let identityDeps: ReadonlyArray - let streamIdentity: unknown = undefined - let identityError: UnhashableQueryIRError | undefined + // The render boundary: a render that throws anywhere after preparation + // never commits, so it releases the sync starts it deferred here. + try { + const queryKey = !inputIsCollection + ? getLiveQueryKey(configOrQueryOrCollection) + : undefined + let preparedQueryValue: unknown | typeof unpreparedQueryValue = + unpreparedQueryValue + let identityDeps: ReadonlyArray + let depsQueryIdentity: unknown = unpreparedQueryValue + let streamIdentity: unknown = undefined + let identityError: UnhashableQueryIRError | undefined - if (queryKey) { - identityDeps = queryKey - streamIdentity = [`queryKey`, queryKey] - } else if (deps !== undefined) { - identityDeps = resolvedDeps - // Deps decide reuse. Only hydration and Suspense read the query hash - // before compiling; every compile checks the identity below. - if (dbClient || forSuspense) { - preparedQueryValue = prepareQueryValue( + if (queryKey) { + identityDeps = queryKey + streamIdentity = [`queryKey`, queryKey] + } else if (deps !== undefined) { + identityDeps = resolvedDeps + // Deps decide reuse. Only hydration and Suspense read the query hash + // before compiling; every compile checks the identity below. + if (dbClient || forSuspense) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + instance.deferredCollections, + ) + depsQueryIdentity = getPreparedLiveQueryIdentity(preparedQueryValue) + streamIdentity = [`deps`, resolvedDeps, depsQueryIdentity] + } + } else if (inputIsCollection) { + identityDeps = [] + streamIdentity = [`collection`, configOrQueryOrCollection.id] + } else { + const preparation = prepareDerivedQuery( configOrQueryOrCollection, - dbClient, - instance.deferredCollections, - ) - streamIdentity = [ - `deps`, - resolvedDeps, - identifyLiveQuery( - preparedQueryValue, - getIdentityScope( - undefined, - dbClient, - `useLiveQuery`, - instance.deferredCollections, - ), + instance.derivedIdentityProfiler, + getIdentityScope( + instance.sourceIds, + dbClient, + `useLiveQuery`, + instance.deferredCollections, ), - ] + ) + preparedQueryValue = preparation.value + identityDeps = preparation.identityDeps + streamIdentity = preparation.identityDeps } - } else if (inputIsCollection) { - identityDeps = [] - streamIdentity = [`collection`, configOrQueryOrCollection.id] - } else { - const preparation = prepareDerivedQuery( - configOrQueryOrCollection, - instance.derivedIdentityProfiler, - getIdentityScope( - instance.sourceIds, - dbClient, - `useLiveQuery`, - instance.deferredCollections, - ), - ) - preparedQueryValue = preparation.value - identityDeps = preparation.identityDeps - streamIdentity = preparation.identityDeps - } - let queryHash: string | undefined - if (streamIdentity !== undefined) { - try { - queryHash = getStableValueHash(streamIdentity, `queryKey`) - } catch (error) { - if (error instanceof UnhashableQueryIRError) { - if (queryKey !== undefined) throw error - identityError = error - } else { - throw error + let queryHash: string | undefined + if (streamIdentity !== undefined) { + try { + queryHash = getStableValueHash(streamIdentity, `queryKey`) + } catch (error) { + if (error instanceof UnhashableQueryIRError) { + if (queryKey !== undefined) throw error + identityError = error + } else { + throw error + } } } - } - if (deps !== undefined) { - warnDeprecatedDepsArray() - } + if (deps !== undefined) { + warnDeprecatedDepsArray() + } - const canReuseSuspenseKey = - forSuspense && - !inputIsCollection && - queryHash !== undefined && - instance.collection !== null && - instance.client === dbClient && - instance.queryHash === queryHash && - instance.suspenseKey !== undefined + const canReuseSuspenseKey = + forSuspense && + !inputIsCollection && + queryHash !== undefined && + instance.collection !== null && + instance.client === dbClient && + instance.queryHash === queryHash && + instance.suspenseKey !== undefined - if ( - forSuspense && - !inputIsCollection && - queryHash && - !canReuseSuspenseKey && - preparedQueryValue === unpreparedQueryValue - ) { - preparedQueryValue = prepareQueryValue( - configOrQueryOrCollection, - dbClient, - instance.deferredCollections, - ) - } + if ( + forSuspense && + !inputIsCollection && + queryHash && + !canReuseSuspenseKey && + preparedQueryValue === unpreparedQueryValue + ) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + instance.deferredCollections, + ) + } - const suspenseKey = - queryHash && forSuspense && !inputIsCollection - ? canReuseSuspenseKey - ? instance.suspenseKey - : getSourceQualifiedSuspenseKey(preparedQueryValue, queryHash, dbClient) - : queryHash + const suspenseKey = + queryHash && forSuspense && !inputIsCollection + ? canReuseSuspenseKey + ? instance.suspenseKey + : getSourceQualifiedSuspenseKey( + preparedQueryValue, + queryHash, + dbClient, + ) + : queryHash - const suspenseCollections = - forSuspense && !inputIsCollection && suspenseKey - ? getSuspenseCollections(dbClient) + const suspenseCollections = + forSuspense && !inputIsCollection && suspenseKey + ? getSuspenseCollections(dbClient) + : undefined + const suspenseEntry = suspenseKey + ? suspenseCollections?.get(suspenseKey) : undefined - const suspenseEntry = suspenseKey - ? suspenseCollections?.get(suspenseKey) - : undefined - const suspenseCollection = suspenseEntry?.collection + const suspenseCollection = suspenseEntry?.collection - const identityChanged = - instance.deps === null || - (deps !== undefined - ? instance.deps.length !== identityDeps.length || - instance.deps.some((dep, index) => dep !== identityDeps[index]) - : !deepEquals(instance.deps, identityDeps)) + const identityChanged = + instance.deps === null || + (deps !== undefined + ? instance.deps.length !== identityDeps.length || + instance.deps.some((dep, index) => dep !== identityDeps[index]) + : !deepEquals(instance.deps, identityDeps)) - // Check if we need to create/recreate the collection - const needsNewCollection = - !instance.collection || - (inputIsCollection && instance.config !== configOrQueryOrCollection) || - (!inputIsCollection && (instance.client !== dbClient || identityChanged)) + // Check if we need to create/recreate the collection + const needsNewCollection = + !instance.collection || + (inputIsCollection && instance.config !== configOrQueryOrCollection) || + (!inputIsCollection && (instance.client !== dbClient || identityChanged)) - if (needsNewCollection) { - if (inputIsCollection) { - // Warn when passing a collection directly with on-demand sync mode - // In on-demand mode, data is only loaded when queries with predicates request it - // Passing the collection directly doesn't provide any predicates, so no data loads - const syncMode = ( - configOrQueryOrCollection as { config?: { syncMode?: string } } - ).config?.syncMode - if ( - syncMode === `on-demand` && - shouldWarnInDevelopment( - `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, - ) && - process.env.NODE_ENV !== `production` - ) { - console.warn( - `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + - `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + - `Instead, use a query builder function:\n` + - ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` + - `Or switch to syncMode "eager" if you want all data to sync automatically.`, - ) - } - // It's already a collection, ensure sync is started for React hooks - configOrQueryOrCollection.startSyncImmediate() - instance.collection = configOrQueryOrCollection - instance.config = configOrQueryOrCollection - } else { - if (suspenseCollection) { - instance.collection = suspenseCollection - } else { - if (preparedQueryValue === unpreparedQueryValue) { - preparedQueryValue = prepareQueryValue( - configOrQueryOrCollection, - dbClient, - instance.deferredCollections, + if (needsNewCollection) { + if (inputIsCollection) { + // Warn when passing a collection directly with on-demand sync mode + // In on-demand mode, data is only loaded when queries with predicates request it + // Passing the collection directly doesn't provide any predicates, so no data loads + const syncMode = ( + configOrQueryOrCollection as { config?: { syncMode?: string } } + ).config?.syncMode + if ( + syncMode === `on-demand` && + shouldWarnInDevelopment( + `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, + ) && + process.env.NODE_ENV !== `production` + ) { + console.warn( + `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + + `Instead, use a query builder function:\n` + + ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` + + `Or switch to syncMode "eager" if you want all data to sync automatically.`, ) } - if (queryKey === undefined && deps !== undefined) { - // A dependency list decides reuse, so check the query when it - // compiles: it needs a stable identity and keeps its sources' IDs. - identifyLiveQuery( - preparedQueryValue, - getIdentityScope( - instance.sourceIds, + // It's already a collection, ensure sync is started for React hooks + configOrQueryOrCollection.startSyncImmediate() + instance.collection = configOrQueryOrCollection + instance.config = configOrQueryOrCollection + } else { + if (suspenseCollection) { + instance.collection = suspenseCollection + } else { + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, dbClient, - `useLiveQuery`, instance.deferredCollections, - ), - ) - } - if (!dbClient) { - getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) - } - try { + ) + } + if (queryKey === undefined && deps !== undefined) { + // A dependency list decides reuse, so check the query when it + // compiles: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery( + preparedQueryValue, + getIdentityScope( + instance.sourceIds, + dbClient, + `useLiveQuery`, + instance.deferredCollections, + ), + // Reuse the identity this render derived for hydration. + depsQueryIdentity === unpreparedQueryValue + ? undefined + : () => depsQueryIdentity, + ) + } + if (!dbClient) { + getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) + } instance.collection = resolveLiveQueryValue(preparedQueryValue, { gcTime: forSuspense ? DEFAULT_SUSPENSE_GC_TIME_MS @@ -941,109 +942,111 @@ function useLiveQueryImpl( // Without a DbClient, Suspense starts it through a preload below. startSync: !forSuspense || dbClient !== undefined, }) as SuspenseCollection | null - } catch (error) { - // A query the live query rejects keeps no sync-start deferral. - rejectLiveQueryInput(instance.deferredCollections, error) - } - if (suspenseCollections && suspenseKey && instance.collection) { - const collection = instance.collection - const removeCleanupListener = collection.on(`status:cleaned-up`, () => - releaseSuspenseCollection( - suspenseCollections, - suspenseKey, - 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. - setTimeout( + if (suspenseCollections && suspenseKey && instance.collection) { + const collection = instance.collection + const removeCleanupListener = collection.on( + `status:cleaned-up`, () => releaseSuspenseCollection( suspenseCollections, suspenseKey, collection, ), - 0, ) - }) - const entry: SuspenseCollectionEntry = { - collection, - removeStatusListeners: () => { - removeCleanupListener() - removeErrorListener() - }, + 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. + setTimeout( + () => + releaseSuspenseCollection( + suspenseCollections, + suspenseKey, + collection, + ), + 0, + ) + }) + const entry: SuspenseCollectionEntry = { + collection, + removeStatusListeners: () => { + removeCleanupListener() + removeErrorListener() + }, + } + suspenseCollections.set(suspenseKey, entry) } - suspenseCollections.set(suspenseKey, entry) } + instance.config = configOrQueryOrCollection + instance.deps = [...identityDeps] } - instance.config = configOrQueryOrCollection - instance.deps = [...identityDeps] + instance.client = dbClient + instance.queryHash = queryHash + instance.suspenseKey = suspenseKey + instance.identityError = identityError } - instance.client = dbClient - instance.queryHash = queryHash - instance.suspenseKey = suspenseKey - instance.identityError = identityError - } - // Recreate the observer when the underlying collection changes. The observer - // is not disposed explicitly here or on unmount: `useSyncExternalStore` - // unsubscribes it when the subscribe changes or the component unmounts, which - // detaches the collection subscription; the observer is then GC'd. (An unmount - // effect that disposed it would misfire under StrictMode/offscreen effect - // replay, leaving a disposed observer in the ref.) - if (needsNewCollection) { - // Defer the initial notify: useSyncExternalStore must not be notified - // synchronously during subscribe. - // Wholesale mode: React re-reads getSnapshot() on notify, keeps the - // hook's pre-observer loading policy, and — because wholesale delivers - // nothing synchronously during subscribe — never notifies - // useSyncExternalStore inside its own subscribe call. - instance.observer = createLiveQueryObserver(instance.collection, { - mode: `wholesale`, - client: dbClient, - queryHash: instance.queryHash, - onPreload: resumeDeferredCollections, - }) - } - const observer = instance.observer! - // A Suspense render asks for its data. Preloading starts the sync run, so a - // source that already holds the rows makes it ready before the snapshot is - // read instead of suspending once. useLiveSuspenseQuery surfaces failures. - // A DbClient defers source starts past render and owns its streamed query. - if ( - forSuspense && - !dbClient && - (instance.collection?.status === `idle` || - instance.collection?.status === `cleaned-up`) - ) { - observer.preloadForInitialRender().catch(() => {}) - } + // Recreate the observer when the underlying collection changes. The observer + // is not disposed explicitly here or on unmount: `useSyncExternalStore` + // unsubscribes it when the subscribe changes or the component unmounts, which + // detaches the collection subscription; the observer is then GC'd. (An unmount + // effect that disposed it would misfire under StrictMode/offscreen effect + // replay, leaving a disposed observer in the ref.) + if (needsNewCollection) { + // Defer the initial notify: useSyncExternalStore must not be notified + // synchronously during subscribe. + // Wholesale mode: React re-reads getSnapshot() on notify, keeps the + // hook's pre-observer loading policy, and — because wholesale delivers + // nothing synchronously during subscribe — never notifies + // useSyncExternalStore inside its own subscribe call. + instance.observer = createLiveQueryObserver(instance.collection, { + mode: `wholesale`, + client: dbClient, + queryHash: instance.queryHash, + onPreload: resumeDeferredCollections, + }) + } + const observer = instance.observer! + // A Suspense render asks for its data. Preloading starts the sync run, so a + // source that already holds the rows makes it ready before the snapshot is + // read instead of suspending once. useLiveSuspenseQuery surfaces failures. + // A DbClient defers source starts past render and owns its streamed query. + if ( + forSuspense && + !dbClient && + (instance.collection?.status === `idle` || + instance.collection?.status === `cleaned-up`) + ) { + observer.preloadForInitialRender().catch(() => {}) + } - // Stable subscribe bound to the current observer; the observer owns the - // subscription, ready-race, and disposal. - if (!instance.subscribe || needsNewCollection) { - instance.subscribe = (onStoreChange: () => void) => { - const unsubscribe = observer.subscribe(onStoreChange) - resumeDeferredCollections() - return unsubscribe + // Stable subscribe bound to the current observer; the observer owns the + // subscription, ready-race, and disposal. + if (!instance.subscribe || needsNewCollection) { + instance.subscribe = (onStoreChange: () => void) => { + const unsubscribe = observer.subscribe(onStoreChange) + resumeDeferredCollections() + return unsubscribe + } } - } - const returned = useSyncExternalStore( - instance.subscribe, - () => observer.getSnapshot(), - () => observer.getServerSnapshot(), - ) - // Only useLiveSuspenseQuery reads this, and it costs a define per render. - if (forSuspense) { - setLiveQueryResultInfo(returned, { - client: dbClient, - queryHash: instance.queryHash, - identityError: instance.identityError, - observer, - }) + const returned = useSyncExternalStore( + instance.subscribe, + () => observer.getSnapshot(), + () => observer.getServerSnapshot(), + ) + // Only useLiveSuspenseQuery reads this, and it costs a define per render. + if (forSuspense) { + setLiveQueryResultInfo(returned, { + client: dbClient, + queryHash: instance.queryHash, + identityError: instance.identityError, + observer, + deferredCollections: instance.deferredCollections, + }) + } + return returned as any + } catch (error) { + return rejectLiveQueryInput(instance.deferredCollections, error) } - return returned as any } diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 28c39bab8e..9f88ee3e66 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -1,6 +1,7 @@ 'use client' import { useRef } from 'react' +import { rejectLiveQueryInput } from '@tanstack/db' import { useLiveQueryForSuspense } from './useLiveQuery' import { getLiveQueryResultInfo } from './live-query-internals' import type { UseLiveQueryConfig } from './useLiveQuery' @@ -229,131 +230,138 @@ export function useLiveSuspenseQuery( } const queryInfo = getLiveQueryResultInfo(result) + // The render boundary: an error thrown before this render commits releases + // the sync starts it deferred. A thrown promise suspends; its preload owns + // those starts. + try { + // Reset promise and ready state when query identity changes + if (collectionRef.current !== result.collection) { + promiseRef.current = null + collectionRef.current = result.collection + hasBeenReadyRef.current = false + } - // Reset promise and ready state when query identity changes - if (collectionRef.current !== result.collection) { - promiseRef.current = null - collectionRef.current = result.collection - hasBeenReadyRef.current = false - } - - // SUSPENSE LOGIC: Throw promise or error based on collection status - - const collectionStatus = result.collection.status + // SUSPENSE LOGIC: Throw promise or error based on collection status - // Track when we reach ready state - if (result.isReady || queryInfo.observer.isInitialRenderReady()) { - hasBeenReadyRef.current = true - promiseRef.current = null - clearInitialRenderError(result.collection, queryInfo.client) - } + const collectionStatus = result.collection.status - const observerError = queryInfo.observer.getError() - // A client request can reject with undefined, which getError() cannot - // distinguish from no error. The request status preserves that distinction. - const clientQuery = - 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 - throw observerError === undefined ? clientQuery?.error : observerError - } + // Track when we reach ready state + if (result.isReady || queryInfo.observer.isInitialRenderReady()) { + hasBeenReadyRef.current = true + promiseRef.current = null + clearInitialRenderError(result.collection, queryInfo.client) + } - const errorEntry = initialRenderErrors.get(result.collection) - const initialRenderError = queryInfo.client - ? errorEntry?.byClient.get(queryInfo.client) - : errorEntry?.unscoped - // A replacement client query must not inherit the prior request's error. - if ( - initialRenderError?.clientQuery !== undefined && - initialRenderError.clientQuery !== clientQuery - ) { - clearInitialRenderError(result.collection, queryInfo.client) - } else if (initialRenderError && !hasBeenReadyRef.current) { - promiseRef.current = null - throw initialRenderError.error - } + const observerError = queryInfo.observer.getError() + // A client request can reject with undefined, which getError() cannot + // distinguish from no error. The request status preserves that distinction. + const clientQuery = + 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 + throw observerError === undefined ? clientQuery?.error : observerError + } - // Only throw errors during initial load (before first ready) - // After success, errors surface as stale data (matches TanStack Query behavior) - if ( - collectionStatus === `error` && - !hasBeenReadyRef.current && - (result.persistedStatus === `unavailable` || - result.persistedStatus === `error`) - ) { - promiseRef.current = null - // TODO: Once collections hold a reference to their last error object (#671), - // we should rethrow that actual error instead of creating a generic message - throw new Error(`Collection "${result.collection.id}" failed to load`) - } + const errorEntry = initialRenderErrors.get(result.collection) + const initialRenderError = queryInfo.client + ? errorEntry?.byClient.get(queryInfo.client) + : errorEntry?.unscoped + // A replacement client query must not inherit the prior request's error. + if ( + initialRenderError?.clientQuery !== undefined && + initialRenderError.clientQuery !== clientQuery + ) { + clearInitialRenderError(result.collection, queryInfo.client) + } else if (initialRenderError && !hasBeenReadyRef.current) { + promiseRef.current = null + throw initialRenderError.error + } - if ( - !hasBeenReadyRef.current && - (result.isLoading || - result.isIdle || - result.isError || - (collectionStatus === `error` && - result.persistedStatus !== `unavailable`)) - ) { - if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { - const reason = queryInfo.identityError - ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` - : `the query has no stable identity` - throw new Error( - `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, - ) + // Only throw errors during initial load (before first ready) + // After success, errors surface as stale data (matches TanStack Query behavior) + if ( + collectionStatus === `error` && + !hasBeenReadyRef.current && + (result.persistedStatus === `unavailable` || + result.persistedStatus === `error`) + ) { + promiseRef.current = null + // TODO: Once collections hold a reference to their last error object (#671), + // we should rethrow that actual error instead of creating a generic message + throw new Error(`Collection "${result.collection.id}" failed to load`) } - // Create or reuse promise for current collection - if (!promiseRef.current) { - const collection = result.collection - const client = queryInfo.client - const queryHash = queryInfo.queryHash - let active = true - const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => { - active = false - }) - let preload: Promise - try { - preload = queryInfo.observer.preloadForInitialRender() - } catch (error) { - stopWatchingCleanup() - throw error + + if ( + !hasBeenReadyRef.current && + (result.isLoading || + result.isIdle || + result.isError || + (collectionStatus === `error` && + result.persistedStatus !== `unavailable`)) + ) { + if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { + const reason = queryInfo.identityError + ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` + : `the query has no stable identity` + throw new Error( + `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, + ) } - const preloadClientQuery = - client && queryHash ? client._getLiveQuery(queryHash) : undefined - promiseRef.current = preload - .catch((error: unknown) => { - const latestClientQuery = - client && queryHash ? client._getLiveQuery(queryHash) : undefined - if (active && latestClientQuery === preloadClientQuery) - rememberInitialRenderError( - collection, - client, - error, - preloadClientQuery, - ) - throw error + // Create or reuse promise for current collection + if (!promiseRef.current) { + const collection = result.collection + const client = queryInfo.client + const queryHash = queryInfo.queryHash + let active = true + const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => { + active = false }) - .finally(stopWatchingCleanup) + let preload: Promise + try { + preload = queryInfo.observer.preloadForInitialRender() + } catch (error) { + stopWatchingCleanup() + throw error + } + const preloadClientQuery = + client && queryHash ? client._getLiveQuery(queryHash) : undefined + promiseRef.current = preload + .catch((error: unknown) => { + const latestClientQuery = + client && queryHash ? client._getLiveQuery(queryHash) : undefined + if (active && latestClientQuery === preloadClientQuery) + rememberInitialRenderError( + collection, + client, + error, + preloadClientQuery, + ) + throw error + }) + .finally(stopWatchingCleanup) + } + // React Suspense catches this promise and retries after preload settles. + throw promiseRef.current } - // React Suspense catches this promise and retries after preload settles. - throw promiseRef.current - } - // Return data without status/loading flags (handled by Suspense/ErrorBoundary) - // If error after success, return last known good state (stale data) - return { - state: result.state, - data: result.data, - collection: result.collection, + // Return data without status/loading flags (handled by Suspense/ErrorBoundary) + // If error after success, return last known good state (stale data) + return { + state: result.state, + data: result.data, + collection: result.collection, + } + } catch (error) { + if (error instanceof Promise) throw error + return rejectLiveQueryInput(queryInfo.deferredCollections, error) } } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 27d42f9bad..e09dcd962e 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -152,30 +152,39 @@ function mountDisabled() { return makeHandle(hook) } -function mountControllable

( - build: (q: any, param: P) => any, - initial: P, -): ControllableHandle

{ - const hook = renderHook( - ({ param }: { param: P }) => - // Param goes in the dependency list so the hook recompiles when it changes. - useLiveQuery((q: any) => build(q, param), [param]), - { initialProps: { param: initial } }, - ) - const guard = rejectReadsAfterRenderError(hook) - const handle = makeHandle(hook) - return { - ...handle, - async setParam(param: P) { - await guard(() => - act(async () => { - hook.rerender({ param }) - }), - ) - await handle.flush() - }, +// React compares query identity on two production paths: the identity derived +// from the query IR on every render (the 1.0 form), and a deprecated +// dependency list, which decides reuse and checks identity when the query +// compiles. The suite runs once per path. +type IdentityPath = `derived` | `dependency list` + +const mountControllableWith = (path: IdentityPath) => + function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + ): ControllableHandle

{ + const hook = renderHook( + ({ param }: { param: P }) => + path === `derived` + ? // The callback captures param, so its IR identity changes with it. + useLiveQuery((q: any) => build(q, param)) + : useLiveQuery((q: any) => build(q, param), [param]), + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook) + return { + ...handle, + async setParam(param: P) { + await guard(() => + act(async () => { + hook.rerender({ param }) + }), + ) + await handle.flush() + }, + } } -} function makeHandle(hook: RenderHookResult) { return { @@ -229,7 +238,7 @@ const reactDriver: LiveQueryDriver = { makePrecreated, makeErrorSource, mount, - mountControllable, + mountControllable: mountControllableWith(`derived`), mountCollection, mountConfig, mountDisabled, @@ -238,6 +247,11 @@ const reactDriver: LiveQueryDriver = { } runSuite(reactDriver) +runSuite({ + ...reactDriver, + name: `react (dependency list)`, + mountControllable: mountControllableWith(`dependency list`), +}) it(`publishes a synchronous ordered limit in the first layout commit`, async () => { const source = makeSource([ { id: `1`, rank: 1 }, diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 0cb7eaf256..dee67ed30c 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -14,6 +14,7 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { mockSyncCollectionOptions } from '../../db/tests/utils' @@ -138,31 +139,40 @@ function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { ) } -function mountControllable

( - build: (q: any, param: P) => any, - initial: P, - config: InfiniteQueryConfig = {}, -) { - const log: Observations = [] - const hook = renderHook( - ({ param }: { param: P }) => - useRecorded( - useLiveInfiniteQuery((q: any) => build(q, param), config as any, [ - param, - ]), - log, - ), - { initialProps: { param: initial } }, - ) - const guard = rejectReadsAfterRenderError(hook) - const handle = makeHandle(hook, log) - return { - ...handle, - setParamSync(param: P) { - guard(() => act(() => hook.rerender({ param }))) - }, +// React compares query identity on two production paths: the identity derived +// from the query IR on every render (the 1.0 form), and a deprecated +// dependency list. The suite runs once per path. +type IdentityPath = `derived` | `dependency list` + +const mountControllableWith = (path: IdentityPath) => + function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, + ) { + const log: Observations = [] + const hook = renderHook( + ({ param }: { param: P }) => + useRecorded( + path === `derived` + ? // The callback captures param, so its IR identity changes with it. + useLiveInfiniteQuery((q: any) => build(q, param), config as any) + : useLiveInfiniteQuery((q: any) => build(q, param), config as any, [ + param, + ]), + log, + ), + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook, log) + return { + ...handle, + setParamSync(param: P) { + guard(() => act(() => hook.rerender({ param }))) + }, + } } -} function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { const log: Observations = [] @@ -251,6 +261,7 @@ const reactInfiniteDriver: InfiniteQueryDriver = { // Each render re-runs the query input and compares its identity. comparesQueryIdentity: true, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), @@ -264,7 +275,7 @@ const reactInfiniteDriver: InfiniteQueryDriver = { makePrecreated, makeWindowController: createLiveQueryWindowController, mount, - mountControllable, + mountControllable: mountControllableWith(`derived`), mountCollection, mountCollectionControllable, mountConfigControllable, @@ -273,6 +284,11 @@ const reactInfiniteDriver: InfiniteQueryDriver = { } runInfiniteQuerySuite(reactInfiniteDriver) +runInfiniteQuerySuite({ + ...reactInfiniteDriver, + name: `react (dependency list)`, + mountControllable: mountControllableWith(`dependency list`), +}) it(`publishes query-function pages on the first layout commit after mount and dependency replacement`, async () => { const source = makeSource( 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 6439ff55cf..f6569dc6c9 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -20,9 +20,10 @@ * The release history grammar crosses every rejection cause (a source ID * collision, a query without a stable IR identity, a descriptor factory that * throws while the query binds its sources, a query the live query rejects - * when it compiles, and a single-result query in an infinite hook, which - * pages many rows), every hook path (live and - * infinite, with derived identity or a dependency list), and both direct + * when it compiles, a single-result query in an infinite hook, which pages + * many rows, and a Suspense query that cannot stream during SSR), every hook + * path (live, infinite, and Suspense, each with derived identity or a + * dependency list), and both direct * readers (another hook over the Collection, and `startSyncImmediate()`). The * release must also hold when earlier deferred sync starts throw: each pending * Collection is attempted before the first startup error is rethrown. @@ -129,8 +130,14 @@ const releaseCauses: Record< ) => { rejected: (step: ReleaseStep) => ReleaseStep query: (q: any, step: ReleaseStep, ordered: boolean) => any - /** Whether the cause rejects in an unordered or ordered (infinite) hook. */ - rejectsIn?: (ordered: boolean) => boolean + /** Which hook paths the cause rejects in; all of them by default. */ + rejectsIn?: (hook: ReleaseHook) => boolean + /** The dependency values for a hook with a dependency list. */ + deps?: (step: ReleaseStep) => Array + /** Prepare the client, for a cause that depends on its mode. */ + setup?: (client: DbClient) => void + /** Render the rejected step first, for a cause only a mount reaches. */ + atMount?: boolean } > = { 'a source ID collision': (shared) => ({ @@ -213,7 +220,21 @@ const releaseCauses: Record< return step.reject ? query.findOne() : query }, // A live query may read one row; an infinite query must page many. - rejectsIn: (ordered) => ordered, + rejectsIn: (hook) => hook.ordered, + }), + 'a Suspense query that cannot stream during SSR': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step) => + q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ), + // A function dependency gives the stream no stable key. + deps: (step) => [step.current, () => step.reject], + setup: (client) => client._setSsrStreamingEnabled(true), + rejectsIn: (hook) => hook.suspense && hook.usesDeps, + atMount: true, }), } @@ -221,30 +242,51 @@ function orderIf(query: any, ordered: boolean) { return ordered ? query.orderBy(({ settings }: any) => settings.rank) : query } -const releaseHooks: Record< - string, - { - ordered: boolean - use: (query: (q: any) => any, deps: Array) => void - } -> = { +type ReleaseHook = { + ordered: boolean + suspense: boolean + usesDeps: boolean + use: (query: (q: any) => any, deps: Array) => void +} + +const releaseHooks: Record = { 'useLiveQuery with derived identity': { ordered: false, + suspense: false, + usesDeps: false, use: (query) => void useLiveQuery(query), }, 'useLiveQuery with a dependency list': { ordered: false, + suspense: false, + usesDeps: true, use: (query, deps) => void useLiveQuery(query, deps), }, 'useLiveInfiniteQuery with derived identity': { ordered: true, + suspense: false, + usesDeps: false, use: (query) => void useLiveInfiniteQuery(query, { pageSize: 1 }), }, 'useLiveInfiniteQuery with a dependency list': { ordered: true, + suspense: false, + usesDeps: true, use: (query, deps) => void useLiveInfiniteQuery(query, { pageSize: 1 }, deps), }, + 'useLiveSuspenseQuery with derived identity': { + ordered: false, + suspense: true, + usesDeps: false, + use: (query) => void useLiveSuspenseQuery(query), + }, + 'useLiveSuspenseQuery with a dependency list': { + ordered: false, + suspense: true, + usesDeps: true, + use: (query, deps) => void useLiveSuspenseQuery(query, deps), + }, } const releaseReaders: Record< @@ -268,7 +310,7 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, for (const [causeName, makeCause] of Object.entries(releaseCauses)) { for (const [hookName, hook] of Object.entries(releaseHooks)) { const probe = makeCause(undefined, undefined) - if (probe.rejectsIn && !probe.rejectsIn(hook.ordered)) continue + if (probe.rejectsIn && !probe.rejectsIn(hook)) continue for (const [readerName, read] of Object.entries(releaseReaders)) { it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source`, async () => { const client = new DbClient() @@ -301,13 +343,23 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, throw new Error(`descriptor factory failed`) }) const cause = makeCause(shared, failing) + cause.setup?.(client) function View({ step }: { step: ReleaseStep }) { hook.use( (q) => cause.query(q, step, hook.ordered), - [step.current, step.reject], + cause.deps?.(step) ?? [step.current, step.reject], ) return

Query
} + const tree = (step: ReleaseStep) => ( + + + Loading}> + + + + + ) const consoleError = vi .spyOn(console, `error`) .mockImplementation(() => {}) @@ -317,20 +369,10 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, reject: false, } const root = render( - - - - - , + tree(cause.atMount ? cause.rejected(initial) : initial), ) await act(async () => {}) - root.rerender( - - - - - , - ) + if (!cause.atMount) root.rerender(tree(cause.rejected(initial))) expect(root.getByText(`Rejected`)).toBeDefined() // Declared reach: the rejected render materialized the shared // descriptor, so it held a deferral the law must release. diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 1e67758e2f..a07622a2b0 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -34,6 +34,42 @@ function createMockPosts(count: number): Array { } describe(`useLiveInfiniteQuery`, () => { + // The hook compares a queryKey with the one it compiled. It must compare a + // snapshot: a caller that changes its key array in place still changes the + // query, as it did before the shared window binding. + it(`recompiles when a queryKey array changes in place`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `mutated-query-key`, + getKey: (post) => post.id, + initialData: createMockPosts(6), + }), + ) + const queryKey: Array = [`category`, `life`] + const { result, rerender } = renderHook(() => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .fn.where(({ post }) => post.category === queryKey[1]) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 10, queryKey }, + ), + ) + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(result.current.data.map((post) => post.id)).toEqual([`1`, `3`, `5`]) + + queryKey[1] = `tech` + rerender() + await waitFor(() => + expect(result.current.data.map((post) => post.id)).toEqual([ + `2`, + `4`, + `6`, + ]), + ) + }) + it(`reclaims a query-function collection abandoned before it commits`, async () => { const source = createCollection( mockSyncCollectionOptions({ diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 29d4df984b..b78029b0c6 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -398,80 +398,80 @@ export function useLiveQuery( // Reference deps to make computed reactive to them const dependencyValues = deps.map((dep) => toValue(dep)) const deferredCollections: DeferredLiveQueryCollections = new Set() - const preparedValue = prepareLiveQueryValue( - unwrappedParam, - dbClient, - deferredCollections, - ) - const configuredQueryKey = ( - unwrappedParam as { queryKey?: MaybeGetter } | null - )?.queryKey - const queryKey = configuredQueryKey - ? toValue(configuredQueryKey) - : undefined - - // A DbClient matches preloaded and hydrated queries by identity hash, so - // a client-backed query without queryKey needs a stable IR identity and - // keeps each source Collection ID's object while the hook is mounted. - // Either rejection releases the sources this preparation deferred. - const derivedHash = - dbClient && !queryKey?.length - ? identifyLiveQuery( - preparedValue, - { - claims: sourceIdClaims, - client: dbClient, - bindingName: `useLiveQuery`, - deferredCollections, - }, - getLiveQueryHash, - ) + // The compile boundary: a query rejected anywhere after preparation + // releases the sync starts it deferred, so a later reader can start them. + try { + const preparedValue = prepareLiveQueryValue( + unwrappedParam, + dbClient, + deferredCollections, + ) + const configuredQueryKey = ( + unwrappedParam as { queryKey?: MaybeGetter } | null + )?.queryKey + const queryKey = configuredQueryKey + ? toValue(configuredQueryKey) : undefined - let queryHash: string | undefined - try { - queryHash = - deps.length > 0 && !queryKey - ? getStableValueHash( - [ - `deps`, - dependencyValues, - derivedHash ?? getLiveQueryHash(preparedValue), - ], - `queryKey`, + // A DbClient matches preloaded and hydrated queries by identity hash, so + // a client-backed query without queryKey needs a stable IR identity and + // keeps each source Collection ID's object while the hook is mounted. + const derivedHash = + dbClient && !queryKey?.length + ? identifyLiveQuery( + preparedValue, + { + claims: sourceIdClaims, + client: dbClient, + bindingName: `useLiveQuery`, + }, + getLiveQueryHash, ) - : (derivedHash ?? getLiveQueryHash(preparedValue, queryKey)) - } catch (error) { - // A dependency value or a client-free query that cannot be hashed - // leaves hydration without a key; it does not reject the query. - if (!(error instanceof UnhashableQueryIRError)) throw error - if (queryKey !== undefined) throw error - } + : undefined - let collection: Collection | null - if (preparedValue === undefined || preparedValue === null) { - collection = null - } else if (isCollection(preparedValue)) { - collection = preparedValue - } else { + let queryHash: string | undefined try { + queryHash = + deps.length > 0 && !queryKey + ? getStableValueHash( + [ + `deps`, + dependencyValues, + derivedHash ?? getLiveQueryHash(preparedValue), + ], + `queryKey`, + ) + : (derivedHash ?? getLiveQueryHash(preparedValue, queryKey)) + } catch (error) { + // A dependency value or a client-free query that cannot be hashed + // leaves hydration without a key; it does not reject the query. An + // empty key counts as none; a nonempty one must hash. + if (!(error instanceof UnhashableQueryIRError)) throw error + if (queryKey?.length) throw error + } + + let collection: Collection | null + if (preparedValue === undefined || preparedValue === null) { + collection = null + } else if (isCollection(preparedValue)) { + collection = preparedValue + } else { // Hydration keys the live-query Collection by identity. collection = resolveLiveQueryValue(preparedValue, { gcTime: DEFAULT_GC_TIME_MS, pool: !dbClient, }) - } catch (error) { - // A query the live query rejects keeps no sync-start deferral. - rejectLiveQueryInput(deferredCollections, error) } - } - return { - collection, - client: dbClient, - queryHash, - resumeDeferredCollections: () => - resumeDeferredLiveQueryCollections(deferredCollections), + return { + collection, + client: dbClient, + queryHash, + resumeDeferredCollections: () => + resumeDeferredLiveQueryCollections(deferredCollections), + } + } catch (error) { + return rejectLiveQueryInput(deferredCollections, error) } }) 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 2a314bd1b3..70a0834ea0 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -11,6 +11,7 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { flushSync, untrack } from 'svelte' @@ -249,6 +250,7 @@ const svelteInfiniteDriver: InfiniteQueryDriver = { // Reactive reads decide when the query compiles again. comparesQueryIdentity: false, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index b6af9407db..b7f2e9137a 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -10,6 +10,7 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { @@ -257,6 +258,7 @@ const vueInfiniteDriver: InfiniteQueryDriver = { // Reactive reads decide when the query compiles again. comparesQueryIdentity: false, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), From 8827acbf9ee46fab332605891677e0ab05754984 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:08:46 -0600 Subject: [PATCH 06/17] chore: refresh reference docs and the db changeset after the boundary release The db changeset now describes rejectLiveQueryInput at each hook's render boundary instead of a release inside prepareLiveQueryValue. Regenerate the reference docs for the changed exports and rename a shadowed join binding in the React source-ID reuse oracle. Co-authored-by: Isaac --- .changeset/db-query-identity-laws.md | 2 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../UseLiveInfiniteQueryConfig.md | 8 ++-- .../UseLiveInfiniteQueryReturn.md | 2 +- ...seLiveInfiniteQueryReturnWithCollection.md | 2 +- .../functions/useLiveInfiniteQuery.md | 4 +- .../react/reference/functions/useLiveQuery.md | 22 +++++----- .../functions/useLiveSuspenseQuery.md | 10 ++--- .../ConditionalUseLiveQueryConfig.md | 2 +- .../reference/type-aliases/LiveQueryKey.md | 2 +- .../UseLiveInfiniteQueryConfig.md | 10 ++--- .../UseLiveInfiniteQueryReturn.md | 2 +- ...seLiveInfiniteQueryReturnWithCollection.md | 40 +++++++++---------- .../type-aliases/UseLiveQueryConfig.md | 2 +- .../type-aliases/UseLiveQueryStatus.md | 2 +- .../functions/bindLiveQueryWindow.md | 2 +- .../functions/claimLiveQuerySourceIds.md | 2 +- .../createLiveQuerySourceIdClaims.md | 2 +- docs/reference/functions/getLiveQueryHash.md | 2 +- docs/reference/functions/getLiveQueryKey.md | 2 +- .../functions/getLiveQueryStatusFlags.md | 2 +- .../functions/getPreparedLiveQueryIdentity.md | 2 +- .../functions/getPreparedLiveQuerySources.md | 2 +- .../functions/getPublicCollection.md | 2 +- .../functions/getSourceObjectToken.md | 2 +- docs/reference/functions/identifyLiveQuery.md | 8 ++-- docs/reference/functions/isCollection.md | 2 +- .../functions/isSingleResultCollection.md | 2 +- .../functions/prepareLiveQueryValue.md | 7 +--- .../functions/readLiveQueryWindowPageShape.md | 2 +- .../functions/rejectLiveQueryInput.md | 31 ++++++++++++++ .../functions/resolveLiveQueryValue.md | 2 +- .../resumeDeferredLiveQueryCollections.md | 2 +- docs/reference/index.md | 1 + .../interfaces/LiveQueryIdentityScope.md | 22 +++------- .../interfaces/LiveQueryStatusFlags.md | 12 +++--- .../interfaces/LiveQueryWindowBinding.md | 20 +++++----- .../interfaces/LiveQueryWindowPageShape.md | 6 +-- .../interfaces/LiveQueryWindowUpdate.md | 14 +++---- .../type-aliases/LiveQuerySourceIdClaims.md | 2 +- .../type-aliases/LiveQueryWindowRecompile.md | 2 +- .../tests/source-id-reuse-oracle.test.tsx | 4 +- 42 files changed, 144 insertions(+), 129 deletions(-) create mode 100644 docs/reference/functions/rejectLiveQueryInput.md diff --git a/.changeset/db-query-identity-laws.md b/.changeset/db-query-identity-laws.md index 56cb2925b0..5408fab225 100644 --- a/.changeset/db-query-identity-laws.md +++ b/.changeset/db-query-identity-laws.md @@ -6,4 +6,4 @@ Collect the helpers the framework adapters share into one adapter layer, exporte Add `SourceCollectionIdReuseError`, which a mounted hook that compares query identity throws when a different source Collection appears under a Collection ID it already read. -`prepareLiveQueryValue` now releases the sync starts it deferred when preparation throws, such as when a collection descriptor's factory fails. Each released source follows its own start policy, so `startSyncImmediate()` or another hook can still start it. +Add `rejectLiveQueryInput`, which a framework hook calls at its render boundary. It releases the sync starts that a rejected render deferred and rethrows the rejection. Each released source follows its own start policy, so `startSyncImmediate()` or another hook can still start it. diff --git a/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md b/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md index 82cbf0adee..7a9c3db48c 100644 --- a/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L85) +Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L86) Page through an ordered live query, growing one live-query Collection's window with `utils.setWindow()` instead of recreating it per page. @@ -64,7 +64,7 @@ const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( function useLiveInfiniteQuery(queryFn, config): UseLiveInfiniteQueryReturn; ``` -Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L94) +Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L95) Page through an ordered live query, growing one live-query Collection's window with `utils.setWindow()` instead of recreating it per page. diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md index 4c4b0cc19b..da63625e89 100644 --- a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig<_TContext> = object; ``` -Defined in: [useLiveInfiniteQuery.ts:26](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L26) +Defined in: [useLiveInfiniteQuery.ts:27](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L27) ## Type Parameters @@ -23,7 +23,7 @@ Defined in: [useLiveInfiniteQuery.ts:26](https://github.com/TanStack/db/blob/mai optional initialPageParam: number; ``` -Defined in: [useLiveInfiniteQuery.ts:34](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L34) +Defined in: [useLiveInfiniteQuery.ts:35](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L35) First result-page label, not a server cursor or remote offset. @@ -35,7 +35,7 @@ First result-page label, not a server cursor or remote offset. optional pageSize: number; ``` -Defined in: [useLiveInfiniteQuery.ts:32](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L32) +Defined in: [useLiveInfiniteQuery.ts:33](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L33) *** @@ -45,7 +45,7 @@ Defined in: [useLiveInfiniteQuery.ts:32](https://github.com/TanStack/db/blob/mai optional queryKey: LiveQueryKey; ``` -Defined in: [useLiveInfiniteQuery.ts:31](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L31) +Defined in: [useLiveInfiniteQuery.ts:32](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L32) Explicit identity for a query whose IR cannot derive one, such as a query with functional variants. Structured queries should omit it. diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md index 1c0883646e..01b5cddcb2 100644 --- a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -8,7 +8,7 @@ type UseLiveInfiniteQueryReturn = UseLiveQueryResult> & InfiniteQueryPagination[number]>; ``` -Defined in: [useLiveInfiniteQuery.ts:46](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L46) +Defined in: [useLiveInfiniteQuery.ts:47](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L47) ## Type Parameters diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index f54fb42677..69f8264436 100644 --- a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturnWithCollection type UseLiveInfiniteQueryReturnWithCollection = UseLiveQueryResult & NonSingleResult> & InfiniteQueryPagination; ``` -Defined in: [useLiveInfiniteQuery.ts:55](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L55) +Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L56) ## Type Parameters diff --git a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md index a946b5459a..1d1cdea03d 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:124](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L124) +Defined in: [useLiveInfiniteQuery.ts:126](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L126) Create an infinite query using a query function with live updates. @@ -57,7 +57,7 @@ function useLiveInfiniteQuery( deps?): UseLiveInfiniteQueryReturn; ``` -Defined in: [useLiveInfiniteQuery.ts:134](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L134) +Defined in: [useLiveInfiniteQuery.ts:136](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L136) 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 be9b9aae38..152844beb7 100644 --- a/docs/framework/react/reference/functions/useLiveQuery.md +++ b/docs/framework/react/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:381](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L381) +Defined in: [useLiveQuery.ts:383](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L383) Create a live query using a query function. @@ -196,7 +196,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:401](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L401) +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. @@ -387,7 +387,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:423](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L423) +Defined in: [useLiveQuery.ts:425](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L425) Create a live query using a query function. @@ -581,7 +581,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:445](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L445) +Defined in: [useLiveQuery.ts:447](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L447) Create a live query using a query function. @@ -780,7 +780,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:471](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L471) +Defined in: [useLiveQuery.ts:473](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L473) Create a live query using a query function. @@ -990,7 +990,7 @@ return ( function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:542](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L542) +Defined in: [useLiveQuery.ts:544](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L544) Create a live query using configuration object @@ -1134,7 +1134,7 @@ return
{data.length} items loaded
function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:561](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L561) +Defined in: [useLiveQuery.ts:563](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L563) Create a live query using a query function. @@ -1317,7 +1317,7 @@ return ( function useLiveQuery(config, deps?): object; ``` -Defined in: [useLiveQuery.ts:580](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L580) +Defined in: [useLiveQuery.ts:582](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L582) Create a live query using a query function. @@ -1502,7 +1502,7 @@ return ( function useLiveQuery(config, deps): object; ``` -Defined in: [useLiveQuery.ts:600](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L600) +Defined in: [useLiveQuery.ts:602](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L602) Create a live query using a query function. @@ -1691,7 +1691,7 @@ return ( function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:649](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L649) +Defined in: [useLiveQuery.ts:651](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L651) Subscribe to an existing live query collection @@ -1837,7 +1837,7 @@ return
{data.map(item => )}
function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:672](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L672) +Defined in: [useLiveQuery.ts:674](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L674) Create a live query using a query function. diff --git a/docs/framework/react/reference/functions/useLiveSuspenseQuery.md b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md index c07e520d8e..9f1030d2ea 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 @@ -162,7 +162,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:162](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L162) Create a live query with React Suspense support @@ -307,7 +307,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:171](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L171) Create a live query with React Suspense support @@ -458,7 +458,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:181](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L181) Create a live query with React Suspense support @@ -610,7 +610,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:194](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L194) Create a live query with React Suspense support diff --git a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md index cc7fc0145e..510e115d38 100644 --- a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryConfig type ConditionalUseLiveQueryConfig = UseLiveQueryConfigOptions & object; ``` -Defined in: [useLiveQuery.ts:135](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L135) +Defined in: [useLiveQuery.ts:137](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L137) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/LiveQueryKey.md b/docs/framework/react/reference/type-aliases/LiveQueryKey.md index 3621a59f4c..091566ad09 100644 --- a/docs/framework/react/reference/type-aliases/LiveQueryKey.md +++ b/docs/framework/react/reference/type-aliases/LiveQueryKey.md @@ -7,4 +7,4 @@ title: LiveQueryKey type LiveQueryKey = ReadonlyArray; ``` -Defined in: [useLiveQuery.ts:111](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L111) +Defined in: [useLiveQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L113) diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md index 47dc4902ed..e5b66073a2 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig<_TContext> = object; ``` -Defined in: [useLiveInfiniteQuery.ts:43](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L43) +Defined in: [useLiveInfiniteQuery.ts:45](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L45) ## Type Parameters @@ -23,7 +23,7 @@ Defined in: [useLiveInfiniteQuery.ts:43](https://github.com/TanStack/db/blob/mai optional client: DbClient; ``` -Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L51) +Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L53) Override the nearest DbProvider for this query. @@ -35,7 +35,7 @@ Override the nearest DbProvider for this query. optional initialPageParam: number; ``` -Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L54) +Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L56) First result-page label, not a server cursor or remote offset. @@ -47,7 +47,7 @@ First result-page label, not a server cursor or remote offset. optional pageSize: number; ``` -Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L52) +Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L54) *** @@ -57,7 +57,7 @@ Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/mai optional queryKey: LiveQueryKey; ``` -Defined in: [useLiveInfiniteQuery.ts:49](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L49) +Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L51) Explicit identity for queries that contain opaque functional variants or are hot enough that deriving identity from structured IR is too expensive. diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md index 5fad4c99d7..39d83d380d 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturn type UseLiveInfiniteQueryReturn = Omit, "data"> & object; ``` -Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L57) +Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L59) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index 397ec7b889..83484bdae2 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:70](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L70) +Defined in: [useLiveInfiniteQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L72) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/mai collection: Collection & NonSingleResult; ``` -Defined in: [useLiveInfiniteQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L77) +Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L79) *** @@ -41,7 +41,7 @@ Defined in: [useLiveInfiniteQuery.ts:77](https://github.com/TanStack/db/blob/mai data: TResult[]; ``` -Defined in: [useLiveInfiniteQuery.ts:75](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L75) +Defined in: [useLiveInfiniteQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L77) *** @@ -51,7 +51,7 @@ Defined in: [useLiveInfiniteQuery.ts:75](https://github.com/TanStack/db/blob/mai error: unknown; ``` -Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) +Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L95) *** @@ -61,7 +61,7 @@ Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/mai fetchNextPage: () => Promise; ``` -Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) +Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) #### Returns @@ -75,7 +75,7 @@ Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/mai hasNextPage: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) +Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) *** @@ -85,7 +85,7 @@ Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/mai isCleanedUp: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) +Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) *** @@ -95,7 +95,7 @@ Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/mai isEnabled: true; ``` -Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) +Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) *** @@ -105,7 +105,7 @@ Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/mai isError: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L85) +Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) *** @@ -115,7 +115,7 @@ Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/mai isFetchingNextPage: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) +Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L94) *** @@ -125,7 +125,7 @@ Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/mai isIdle: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L84) +Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) *** @@ -135,7 +135,7 @@ Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/mai isLoading: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L79) +Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L81) *** @@ -145,7 +145,7 @@ Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/mai isPersistedReady: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L82) +Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L84) *** @@ -155,7 +155,7 @@ Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/mai isReady: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L80) +Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L82) *** @@ -165,7 +165,7 @@ Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/mai pageParams: number[]; ``` -Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) +Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) *** @@ -175,7 +175,7 @@ Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/mai pages: TResult[][]; ``` -Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) +Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) *** @@ -185,7 +185,7 @@ Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/mai persistedError: unknown | undefined; ``` -Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L83) +Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L85) *** @@ -195,7 +195,7 @@ Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/mai persistedStatus: LiveQueryPersistedStatus; ``` -Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L81) +Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L83) *** @@ -205,7 +205,7 @@ Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/mai state: Map; ``` -Defined in: [useLiveInfiniteQuery.ts:76](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L76) +Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L78) *** @@ -215,4 +215,4 @@ Defined in: [useLiveInfiniteQuery.ts:76](https://github.com/TanStack/db/blob/mai status: CollectionStatus; ``` -Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L78) +Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L80) diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md index c1d5e8c30a..6c417654a4 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveQueryConfig type UseLiveQueryConfig = UseLiveQueryConfigOptions & Pick, "query">; ``` -Defined in: [useLiveQuery.ts:131](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L131) +Defined in: [useLiveQuery.ts:133](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L133) ## Type Parameters diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md index 367e197d52..c31d9c5f24 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md @@ -7,4 +7,4 @@ title: UseLiveQueryStatus type UseLiveQueryStatus = CollectionStatus | "disabled"; ``` -Defined in: [useLiveQuery.ts:110](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L110) +Defined in: [useLiveQuery.ts:112](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L112) diff --git a/docs/reference/functions/bindLiveQueryWindow.md b/docs/reference/functions/bindLiveQueryWindow.md index 5a96db2996..9f17bfb4c6 100644 --- a/docs/reference/functions/bindLiveQueryWindow.md +++ b/docs/reference/functions/bindLiveQueryWindow.md @@ -10,7 +10,7 @@ function bindLiveQueryWindow( uncommitted?): LiveQueryWindowBinding; ``` -Defined in: packages/db/src/live-query-window-binding.ts:111 +Defined in: [packages/db/src/live-query-window-binding.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L111) Bind an infinite-query update. Returns `previous` when it still serves the update; otherwise returns a new binding whose controller the adapter diff --git a/docs/reference/functions/claimLiveQuerySourceIds.md b/docs/reference/functions/claimLiveQuerySourceIds.md index e8234d6721..46b1c0190a 100644 --- a/docs/reference/functions/claimLiveQuerySourceIds.md +++ b/docs/reference/functions/claimLiveQuerySourceIds.md @@ -11,7 +11,7 @@ function claimLiveQuerySourceIds( bindingName): void; ``` -Defined in: [packages/db/src/live-query-adapter.ts:100](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L100) +Defined in: [packages/db/src/live-query-adapter.ts:97](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L97) Record a query's sources, or throw before recording any. diff --git a/docs/reference/functions/createLiveQuerySourceIdClaims.md b/docs/reference/functions/createLiveQuerySourceIdClaims.md index 1c5f4a6dce..b08eb16cab 100644 --- a/docs/reference/functions/createLiveQuerySourceIdClaims.md +++ b/docs/reference/functions/createLiveQuerySourceIdClaims.md @@ -7,7 +7,7 @@ title: createLiveQuerySourceIdClaims function createLiveQuerySourceIdClaims(): LiveQuerySourceIdClaims; ``` -Defined in: [packages/db/src/live-query-adapter.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L95) +Defined in: [packages/db/src/live-query-adapter.ts:92](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L92) ## Returns diff --git a/docs/reference/functions/getLiveQueryHash.md b/docs/reference/functions/getLiveQueryHash.md index fc7b2e6d7b..62975925c8 100644 --- a/docs/reference/functions/getLiveQueryHash.md +++ b/docs/reference/functions/getLiveQueryHash.md @@ -7,7 +7,7 @@ title: getLiveQueryHash function getLiveQueryHash(preparedValue, queryKey?): string; ``` -Defined in: [packages/db/src/live-query-options.ts:228](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L228) +Defined in: [packages/db/src/live-query-options.ts:213](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L213) ## Parameters diff --git a/docs/reference/functions/getLiveQueryKey.md b/docs/reference/functions/getLiveQueryKey.md index 5bc0095576..78059f9f51 100644 --- a/docs/reference/functions/getLiveQueryKey.md +++ b/docs/reference/functions/getLiveQueryKey.md @@ -7,7 +7,7 @@ title: getLiveQueryKey function getLiveQueryKey(input): LiveQueryKey | undefined; ``` -Defined in: [packages/db/src/live-query-adapter.ts:52](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L52) +Defined in: [packages/db/src/live-query-adapter.ts:49](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L49) The `queryKey` of a config-object query input, if it supplies one. An empty key is absent, as it is for `getLiveQueryHash`, so a hook and a `DbClient` diff --git a/docs/reference/functions/getLiveQueryStatusFlags.md b/docs/reference/functions/getLiveQueryStatusFlags.md index 0bf0a0f167..a8eee8db21 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-primitives.ts:64 +Defined in: [packages/db/src/live-query-primitives.ts:64](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L64) 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/getPreparedLiveQueryIdentity.md b/docs/reference/functions/getPreparedLiveQueryIdentity.md index 5877205e0b..586d29aea7 100644 --- a/docs/reference/functions/getPreparedLiveQueryIdentity.md +++ b/docs/reference/functions/getPreparedLiveQueryIdentity.md @@ -7,7 +7,7 @@ title: getPreparedLiveQueryIdentity function getPreparedLiveQueryIdentity(value): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:200](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L200) +Defined in: [packages/db/src/live-query-options.ts:185](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L185) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQuerySources.md b/docs/reference/functions/getPreparedLiveQuerySources.md index 4c142c6129..3f4678d9a4 100644 --- a/docs/reference/functions/getPreparedLiveQuerySources.md +++ b/docs/reference/functions/getPreparedLiveQuerySources.md @@ -7,7 +7,7 @@ title: getPreparedLiveQuerySources function getPreparedLiveQuerySources(value, onUnboundDescriptor?): Collection, string | number, UtilsRecord, StandardSchemaV1, Record>[]; ``` -Defined in: [packages/db/src/live-query-options.ts:177](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L177) +Defined in: [packages/db/src/live-query-options.ts:162](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L162) Concrete source objects in query-position order for a prepared value. diff --git a/docs/reference/functions/getPublicCollection.md b/docs/reference/functions/getPublicCollection.md index 9ef5dce7c2..80215808fd 100644 --- a/docs/reference/functions/getPublicCollection.md +++ b/docs/reference/functions/getPublicCollection.md @@ -7,7 +7,7 @@ title: getPublicCollection function getPublicCollection(collection): T; ``` -Defined in: packages/db/src/live-query-primitives.ts:31 +Defined in: [packages/db/src/live-query-primitives.ts:31](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L31) The Collection an adapter hands users as `collection`. A pooled live query stands in for its live-query Collection and builds it only when touched. diff --git a/docs/reference/functions/getSourceObjectToken.md b/docs/reference/functions/getSourceObjectToken.md index 76a91cd558..5d38601999 100644 --- a/docs/reference/functions/getSourceObjectToken.md +++ b/docs/reference/functions/getSourceObjectToken.md @@ -7,7 +7,7 @@ title: getSourceObjectToken function getSourceObjectToken(source): number; ``` -Defined in: [packages/db/src/live-query-adapter.ts:77](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L77) +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) A process-unique number for a source object, without retaining it. diff --git a/docs/reference/functions/identifyLiveQuery.md b/docs/reference/functions/identifyLiveQuery.md index 883dda2975..37bb2e87a8 100644 --- a/docs/reference/functions/identifyLiveQuery.md +++ b/docs/reference/functions/identifyLiveQuery.md @@ -10,13 +10,13 @@ function identifyLiveQuery( identify): TIdentity; ``` -Defined in: [packages/db/src/live-query-adapter.ts:141](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L141) +Defined in: [packages/db/src/live-query-adapter.ts:136](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L136) Identify a prepared query for a binding that compares query identity. The identity throws `UnhashableQueryIRError` for IR without a stable identity, -then the query's sources claim their Collection IDs. Either rejection -releases the sync starts the preparation deferred, so a later reader, such -as another hook over the same Collection, can still start those sources. +then the query's sources claim their Collection IDs. The binding's render +boundary releases the sync starts a rejected render deferred; see +`rejectLiveQueryInput`. ## Type Parameters diff --git a/docs/reference/functions/isCollection.md b/docs/reference/functions/isCollection.md index 6238dfa28c..3738931fa5 100644 --- a/docs/reference/functions/isCollection.md +++ b/docs/reference/functions/isCollection.md @@ -7,7 +7,7 @@ title: isCollection function isCollection(value): value is Collection, any>; ``` -Defined in: packages/db/src/live-query-primitives.ts:15 +Defined in: [packages/db/src/live-query-primitives.ts:15](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L15) Structural check for a live-query/`Collection` instance. diff --git a/docs/reference/functions/isSingleResultCollection.md b/docs/reference/functions/isSingleResultCollection.md index 091ad63055..9c6730ba5c 100644 --- a/docs/reference/functions/isSingleResultCollection.md +++ b/docs/reference/functions/isSingleResultCollection.md @@ -7,7 +7,7 @@ title: isSingleResultCollection function isSingleResultCollection(collection): boolean; ``` -Defined in: packages/db/src/live-query-primitives.ts:41 +Defined in: [packages/db/src/live-query-primitives.ts:41](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L41) Whether a collection yields a single result (`findOne`) rather than an array. diff --git a/docs/reference/functions/prepareLiveQueryValue.md b/docs/reference/functions/prepareLiveQueryValue.md index be2dcb70be..17288ae407 100644 --- a/docs/reference/functions/prepareLiveQueryValue.md +++ b/docs/reference/functions/prepareLiveQueryValue.md @@ -10,12 +10,7 @@ function prepareLiveQueryValue( deferredCollections?): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:112](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L112) - -Bind a query input's descriptor sources to `dbClient` and invoke its query -callback. Sources materialized for a render defer their sync start into -`deferredCollections`; a preparation that throws releases them, so a later -reader can still start those sources. +Defined in: [packages/db/src/live-query-options.ts:109](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L109) ## Parameters diff --git a/docs/reference/functions/readLiveQueryWindowPageShape.md b/docs/reference/functions/readLiveQueryWindowPageShape.md index 2981d7c4bf..2d0004976c 100644 --- a/docs/reference/functions/readLiveQueryWindowPageShape.md +++ b/docs/reference/functions/readLiveQueryWindowPageShape.md @@ -7,7 +7,7 @@ title: readLiveQueryWindowPageShape function readLiveQueryWindowPageShape(config): LiveQueryWindowPageShape; ``` -Defined in: packages/db/src/live-query-window-binding.ts:43 +Defined in: [packages/db/src/live-query-window-binding.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L43) Read an infinite-query config's page shape. `getNextPageParam` is rejected: the hook widens an ordered live query and has no server cursor to advance. diff --git a/docs/reference/functions/rejectLiveQueryInput.md b/docs/reference/functions/rejectLiveQueryInput.md new file mode 100644 index 0000000000..43afa30888 --- /dev/null +++ b/docs/reference/functions/rejectLiveQueryInput.md @@ -0,0 +1,31 @@ +--- +id: rejectLiveQueryInput +title: rejectLiveQueryInput +--- + +```ts +function rejectLiveQueryInput(collections, rejection): never; +``` + +Defined in: [packages/db/src/live-query-options.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L43) + +Release the sync starts a rejected query input deferred, then rethrow its +rejection. A framework binding calls this at its render boundary, so a +render that throws anywhere after preparation leaves no source deferred and +a later reader can start it. As for a `DbClient` preload, the rejection wins +over a source whose resumed start fails; that source records its own +failure. + +## Parameters + +### collections + +[`DeferredLiveQueryCollections`](../type-aliases/DeferredLiveQueryCollections.md) | `undefined` + +### rejection + +`unknown` + +## Returns + +`never` diff --git a/docs/reference/functions/resolveLiveQueryValue.md b/docs/reference/functions/resolveLiveQueryValue.md index 86bceeaf72..9768b0d8f6 100644 --- a/docs/reference/functions/resolveLiveQueryValue.md +++ b/docs/reference/functions/resolveLiveQueryValue.md @@ -9,7 +9,7 @@ function resolveLiveQueryValue(value, __namedParameters): | null; ``` -Defined in: [packages/db/src/live-query-options.ts:264](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L264) +Defined in: [packages/db/src/live-query-options.ts:249](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L249) Resolve an adapter's query value to what its observer watches: `null` for a disabled query, an existing Collection with sync started, or a live query. diff --git a/docs/reference/functions/resumeDeferredLiveQueryCollections.md b/docs/reference/functions/resumeDeferredLiveQueryCollections.md index 3b1708e9f0..c7d703eb51 100644 --- a/docs/reference/functions/resumeDeferredLiveQueryCollections.md +++ b/docs/reference/functions/resumeDeferredLiveQueryCollections.md @@ -7,7 +7,7 @@ title: resumeDeferredLiveQueryCollections function resumeDeferredLiveQueryCollections(collections): void; ``` -Defined in: [packages/db/src/live-query-options.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L53) +Defined in: [packages/db/src/live-query-options.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L56) Release every deferred source, then report the first startup failure. diff --git a/docs/reference/index.md b/docs/reference/index.md index 1260894b5e..3dd1474dbd 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -440,6 +440,7 @@ title: "@tanstack/db" - [queryOnce](functions/queryOnce.md) - [queueStrategy](functions/queueStrategy.md) - [readLiveQueryWindowPageShape](functions/readLiveQueryWindowPageShape.md) +- [rejectLiveQueryInput](functions/rejectLiveQueryInput.md) - [resetWarnings](functions/resetWarnings.md) - [resolveLiveQueryValue](functions/resolveLiveQueryValue.md) - [resumeDeferredLiveQueryCollections](functions/resumeDeferredLiveQueryCollections.md) diff --git a/docs/reference/interfaces/LiveQueryIdentityScope.md b/docs/reference/interfaces/LiveQueryIdentityScope.md index 74e055fd2e..39b62b82aa 100644 --- a/docs/reference/interfaces/LiveQueryIdentityScope.md +++ b/docs/reference/interfaces/LiveQueryIdentityScope.md @@ -3,7 +3,7 @@ id: LiveQueryIdentityScope title: LiveQueryIdentityScope --- -Defined in: [packages/db/src/live-query-adapter.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L123) +Defined in: [packages/db/src/live-query-adapter.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L120) Where a binding that compares query identity checks a prepared query. @@ -15,7 +15,7 @@ Where a binding that compares query identity checks a prepared query. bindingName: string; ``` -Defined in: [packages/db/src/live-query-adapter.ts:127](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L127) +Defined in: [packages/db/src/live-query-adapter.ts:124](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L124) *** @@ -25,7 +25,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:127](https://github.com/TanSt optional claims: LiveQuerySourceIdClaims; ``` -Defined in: [packages/db/src/live-query-adapter.ts:125](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L125) +Defined in: [packages/db/src/live-query-adapter.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L122) This binding's claims; omit to check identity without claiming. @@ -37,19 +37,7 @@ This binding's claims; omit to check identity without claiming. client: DbClient | undefined; ``` -Defined in: [packages/db/src/live-query-adapter.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L126) - -*** - -### deferredCollections? - -```ts -optional deferredCollections: DeferredLiveQueryCollections; -``` - -Defined in: [packages/db/src/live-query-adapter.ts:129](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L129) - -Sync starts the query's preparation deferred. +Defined in: [packages/db/src/live-query-adapter.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L123) *** @@ -59,7 +47,7 @@ Sync starts the query's preparation deferred. optional onUnboundDescriptor: (alias) => never; ``` -Defined in: [packages/db/src/live-query-adapter.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L131) +Defined in: [packages/db/src/live-query-adapter.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L126) Report a descriptor source that no client bound. diff --git a/docs/reference/interfaces/LiveQueryStatusFlags.md b/docs/reference/interfaces/LiveQueryStatusFlags.md index 51bbe63c6e..9322ea903e 100644 --- a/docs/reference/interfaces/LiveQueryStatusFlags.md +++ b/docs/reference/interfaces/LiveQueryStatusFlags.md @@ -3,7 +3,7 @@ id: LiveQueryStatusFlags title: LiveQueryStatusFlags --- -Defined in: packages/db/src/live-query-primitives.ts:51 +Defined in: [packages/db/src/live-query-primitives.ts:51](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L51) The derived boolean status flags every adapter exposes for a query. @@ -15,7 +15,7 @@ The derived boolean status flags every adapter exposes for a query. isCleanedUp: boolean; ``` -Defined in: packages/db/src/live-query-primitives.ts:56 +Defined in: [packages/db/src/live-query-primitives.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L56) *** @@ -25,7 +25,7 @@ Defined in: packages/db/src/live-query-primitives.ts:56 isError: boolean; ``` -Defined in: packages/db/src/live-query-primitives.ts:55 +Defined in: [packages/db/src/live-query-primitives.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L55) *** @@ -35,7 +35,7 @@ Defined in: packages/db/src/live-query-primitives.ts:55 isIdle: boolean; ``` -Defined in: packages/db/src/live-query-primitives.ts:54 +Defined in: [packages/db/src/live-query-primitives.ts:54](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L54) *** @@ -45,7 +45,7 @@ Defined in: packages/db/src/live-query-primitives.ts:54 isLoading: boolean; ``` -Defined in: packages/db/src/live-query-primitives.ts:52 +Defined in: [packages/db/src/live-query-primitives.ts:52](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L52) *** @@ -55,4 +55,4 @@ Defined in: packages/db/src/live-query-primitives.ts:52 isReady: boolean; ``` -Defined in: packages/db/src/live-query-primitives.ts:53 +Defined in: [packages/db/src/live-query-primitives.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L53) diff --git a/docs/reference/interfaces/LiveQueryWindowBinding.md b/docs/reference/interfaces/LiveQueryWindowBinding.md index 5e26861db1..15784379c2 100644 --- a/docs/reference/interfaces/LiveQueryWindowBinding.md +++ b/docs/reference/interfaces/LiveQueryWindowBinding.md @@ -3,7 +3,7 @@ id: LiveQueryWindowBinding title: LiveQueryWindowBinding --- -Defined in: packages/db/src/live-query-window-binding.ts:86 +Defined in: [packages/db/src/live-query-window-binding.ts:86](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L86) Validated infinite-query page shape. @@ -19,7 +19,7 @@ Validated infinite-query page shape. collection: Collection; ``` -Defined in: packages/db/src/live-query-window-binding.ts:91 +Defined in: [packages/db/src/live-query-window-binding.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L91) *** @@ -29,7 +29,7 @@ Defined in: packages/db/src/live-query-window-binding.ts:91 controller: LiveQueryWindowController; ``` -Defined in: packages/db/src/live-query-window-binding.ts:94 +Defined in: [packages/db/src/live-query-window-binding.ts:94](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L94) *** @@ -39,7 +39,7 @@ Defined in: packages/db/src/live-query-window-binding.ts:94 dependencies: readonly unknown[] | undefined; ``` -Defined in: packages/db/src/live-query-window-binding.ts:89 +Defined in: [packages/db/src/live-query-window-binding.ts:89](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L89) *** @@ -49,7 +49,7 @@ Defined in: packages/db/src/live-query-window-binding.ts:89 initialPageParam: number; ``` -Defined in: packages/db/src/live-query-window-binding.ts:36 +Defined in: [packages/db/src/live-query-window-binding.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L36) First result-page label, not a server cursor or remote offset. @@ -65,7 +65,7 @@ First result-page label, not a server cursor or remote offset. ownsCollection: boolean; ``` -Defined in: packages/db/src/live-query-window-binding.ts:93 +Defined in: [packages/db/src/live-query-window-binding.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L93) Whether this binding created `collection` from a query. @@ -77,7 +77,7 @@ Whether this binding created `collection` from a query. pageSize: number; ``` -Defined in: packages/db/src/live-query-window-binding.ts:34 +Defined in: [packages/db/src/live-query-window-binding.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L34) #### Inherited from @@ -91,7 +91,7 @@ Defined in: packages/db/src/live-query-window-binding.ts:34 scope: unknown; ``` -Defined in: packages/db/src/live-query-window-binding.ts:90 +Defined in: [packages/db/src/live-query-window-binding.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L90) *** @@ -103,7 +103,7 @@ supplied: | undefined; ``` -Defined in: packages/db/src/live-query-window-binding.ts:88 +Defined in: [packages/db/src/live-query-window-binding.ts:88](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L88) The supplied Collection; `undefined` for a query input. @@ -115,7 +115,7 @@ The supplied Collection; `undefined` for a query input. warning: string | undefined; ``` -Defined in: packages/db/src/live-query-window-binding.ts:99 +Defined in: [packages/db/src/live-query-window-binding.ts:99](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L99) A developer hint about a newly bound supplied collection's window, for the adapter to print once. Production builds carry none. diff --git a/docs/reference/interfaces/LiveQueryWindowPageShape.md b/docs/reference/interfaces/LiveQueryWindowPageShape.md index b94a4331d9..367b7a71cc 100644 --- a/docs/reference/interfaces/LiveQueryWindowPageShape.md +++ b/docs/reference/interfaces/LiveQueryWindowPageShape.md @@ -3,7 +3,7 @@ id: LiveQueryWindowPageShape title: LiveQueryWindowPageShape --- -Defined in: packages/db/src/live-query-window-binding.ts:33 +Defined in: [packages/db/src/live-query-window-binding.ts:33](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L33) Validated infinite-query page shape. @@ -20,7 +20,7 @@ Validated infinite-query page shape. initialPageParam: number; ``` -Defined in: packages/db/src/live-query-window-binding.ts:36 +Defined in: [packages/db/src/live-query-window-binding.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L36) First result-page label, not a server cursor or remote offset. @@ -32,4 +32,4 @@ First result-page label, not a server cursor or remote offset. pageSize: number; ``` -Defined in: packages/db/src/live-query-window-binding.ts:34 +Defined in: [packages/db/src/live-query-window-binding.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L34) diff --git a/docs/reference/interfaces/LiveQueryWindowUpdate.md b/docs/reference/interfaces/LiveQueryWindowUpdate.md index 175c2d4645..1f448c3a21 100644 --- a/docs/reference/interfaces/LiveQueryWindowUpdate.md +++ b/docs/reference/interfaces/LiveQueryWindowUpdate.md @@ -3,7 +3,7 @@ id: LiveQueryWindowUpdate title: LiveQueryWindowUpdate --- -Defined in: packages/db/src/live-query-window-binding.ts:72 +Defined in: [packages/db/src/live-query-window-binding.ts:72](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L72) Validated infinite-query page shape. @@ -19,7 +19,7 @@ Validated infinite-query page shape. optional dependencies: readonly unknown[]; ``` -Defined in: packages/db/src/live-query-window-binding.ts:80 +Defined in: [packages/db/src/live-query-window-binding.ts:80](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L80) The query input's identity dependencies. @@ -31,7 +31,7 @@ The query input's identity dependencies. initialPageParam: number; ``` -Defined in: packages/db/src/live-query-window-binding.ts:36 +Defined in: [packages/db/src/live-query-window-binding.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L36) First result-page label, not a server cursor or remote offset. @@ -47,7 +47,7 @@ First result-page label, not a server cursor or remote offset. input: unknown; ``` -Defined in: packages/db/src/live-query-window-binding.ts:78 +Defined in: [packages/db/src/live-query-window-binding.ts:78](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L78) A supplied live-query Collection, or a callback that returns the query. The binding calls the callback only when it compiles a new window @@ -61,7 +61,7 @@ collection, except in `always` mode, where it calls it on every update. pageSize: number; ``` -Defined in: packages/db/src/live-query-window-binding.ts:34 +Defined in: [packages/db/src/live-query-window-binding.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L34) #### Inherited from @@ -75,7 +75,7 @@ Defined in: packages/db/src/live-query-window-binding.ts:34 recompile: LiveQueryWindowRecompile; ``` -Defined in: packages/db/src/live-query-window-binding.ts:83 +Defined in: [packages/db/src/live-query-window-binding.ts:83](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L83) *** @@ -85,6 +85,6 @@ Defined in: packages/db/src/live-query-window-binding.ts:83 optional scope: unknown; ``` -Defined in: packages/db/src/live-query-window-binding.ts:82 +Defined in: [packages/db/src/live-query-window-binding.ts:82](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L82) Context compared by reference, such as the `DbClient` a query binds to. diff --git a/docs/reference/type-aliases/LiveQuerySourceIdClaims.md b/docs/reference/type-aliases/LiveQuerySourceIdClaims.md index 019ed75f0c..086949da7f 100644 --- a/docs/reference/type-aliases/LiveQuerySourceIdClaims.md +++ b/docs/reference/type-aliases/LiveQuerySourceIdClaims.md @@ -7,7 +7,7 @@ title: LiveQuerySourceIdClaims type LiveQuerySourceIdClaims = WeakMap>; ``` -Defined in: [packages/db/src/live-query-adapter.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L93) +Defined in: [packages/db/src/live-query-adapter.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L90) The source object each Collection ID named while one binding is mounted, per client scope. diff --git a/docs/reference/type-aliases/LiveQueryWindowRecompile.md b/docs/reference/type-aliases/LiveQueryWindowRecompile.md index 530be6c4e3..0814d52722 100644 --- a/docs/reference/type-aliases/LiveQueryWindowRecompile.md +++ b/docs/reference/type-aliases/LiveQueryWindowRecompile.md @@ -7,7 +7,7 @@ title: LiveQueryWindowRecompile type LiveQueryWindowRecompile = "identity" | "dependencies" | "always"; ``` -Defined in: packages/db/src/live-query-window-binding.ts:70 +Defined in: [packages/db/src/live-query-window-binding.ts:70](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L70) When a query input compiles again. 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 f6569dc6c9..16d7480ce3 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -446,8 +446,8 @@ describe(`React source ID reuse`, () => { .join({ failingB: failing[1]! }, ({ failingA, failingB }) => eq(failingA.id, failingB.id), ) - .join({ healthy }, ({ failingA, healthy }) => - eq(failingA.id, healthy.id), + .join({ healthy }, ({ failingA, healthy: row }) => + eq(failingA.id, row.id), ) .join({ settings: current }, ({ failingA, settings }) => eq(failingA.id, settings.id), From 5dba1d55f2f6f701627e1dca96fcdd1817d3d113 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:33:48 -0600 Subject: [PATCH 07/17] fix: close the second high review's findings - Suspense: a dependency-list useLiveSuspenseQuery that takes a cached Suspense collection now claims its source IDs too (S3). - Svelte: an empty queryKey counts as none in the dependency hash (S8). - Claim grammar: reach labels name the steps between a claim and its test, so a direct swap no longer counts as "after an intervening query" (S1). - Same-ID join histories use overlapping but different rows (S2). - Svelte reads every public result field after a collision (S5). - React and Preact witnesses: a keyed query swaps a same-ID source when its key changes (S6). - React's dependency-list conformance runs mount and config mount through a dependency list (S4). - Contract prose: Preact keeps a throwing component mounted, so its driver uses an error boundary (S7). Stale oracle headers fixed (S9). The abandoned-concurrent-render claim (S10) is recorded as an open decision. Evidence: - Law: comparing hooks claim each source ID whenever they bind a live query, compiled or cached; a nonempty queryKey skips the claim; claims outlive intervening and disabled queries (contract.ts, React source-ID reuse oracle). - Reach: claim grammar reaches swap-after-intervening-query, swap-after-disabled-step, and revisit-after-intervening-query; with the C(y) step removed, reach fails. - RED (8827acbf9): a dependency-list Suspense hook with a hashable dependency takes the cached collection without claiming (probe: 3 cache hits) and accepts a same-ID swap, in a retry and a sibling history. - GREEN: react-db 568, preact-db 98, svelte-db 164, vue-db 140, solid-db 103, angular-db 77, db 13790. - Mutant: an isError getter without the compiled-query guard fails 6 Svelte cells; skipping the dependency-list check at first compile fails 5 React scenarios; a key reader that ignores keys fails both keyed-swap witnesses; reading either same-ID object for both join sides now fails. - Limits: S8 has no observable witness (it changes only the DbClient hash). A Preact component without an error boundary is outside the cut. S10 awaits a maintainer decision. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/tests/conformance/contract.ts | 8 +- .../conformance/infinite-suite-oracle.ts | 16 +-- packages/db/tests/conformance/suite.ts | 71 +++++++--- packages/preact-db/tests/useLiveQuery.test.ts | 23 ++++ packages/react-db/src/useLiveQuery.ts | 49 +++---- packages/react-db/tests/conformance.test.tsx | 19 +++ .../tests/infinite-query-conformance.test.tsx | 15 +++ .../tests/source-id-reuse-oracle.test.tsx | 124 +++++++++++++++++- packages/svelte-db/src/useLiveQuery.svelte.ts | 2 +- ...riptor-query-release-oracle.svelte.test.ts | 14 +- 11 files changed, 280 insertions(+), 63 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 1cd6b32e15..23b1cbbdac 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Claims that keep only the latest query, or reset at a disabled step, fail it. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check: React and Preact drivers rethrow on later reads because the failed hook is unmounted, and reactive drivers do not reject. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads `data`, `state`, `status`, and `isReady` separately after the rejection. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows, so reading either object for both sides fails. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Open decision, owner the maintainer: React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim (trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`). The React oracle states this on purpose for a render that suspends, because its live query may still own the source until cleanup; the abandoned-transition case has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 253705fb93..767f510e99 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -50,9 +50,11 @@ * each `current()` throws or holds none, so a framework whose memo throws once * cannot fall back to the previous query's rows. Each driver releases any * native scope that a rejected mount acquired. In the current drivers this - * check cannot fail: React and Preact unmount a hook whose render throws, so - * their drivers rethrow on later reads, and reactive drivers do not reject - * here. It guards a future driver whose binding stays mounted. The one such + * check cannot fail, and reactive drivers do not reject here. React unmounts + * a tree whose render throws, so its driver rethrows on later reads. Preact + * keeps a component whose render throws mounted, so its driver mounts the hook + * under an error boundary, as an application must to recover, and rethrows on + * later reads. A Preact component without a boundary is outside this cut. It guards a future driver whose binding stays mounted. The one such * binding today, a `DbClient`-backed Svelte query, has its own witness in the * Svelte descriptor release oracle, which reads each field after a rejection. * diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 83b3a7019e..d4d68986bf 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -1809,8 +1809,9 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { `source-id-reuse-in-one-query`, `rejects or pages one query that reads two sources under one Collection ID`, async () => { + // Overlapping but different rows: ids 1-4 and 3-6. const first = driver.makeSource(rows(4)) - const reused = driver.makeSource(rows(2), first.collection.id) + const reused = driver.makeSource(rows(6).slice(2), first.collection.id) const build: QueryBuild = (q) => q .from({ items: first.collection }) @@ -1828,15 +1829,14 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { expect(error).toMatchObject({ name: `SourceCollectionIdReuseError` }) return } - // The inner join matches only the ids both objects hold, so a binding - // that read one object for both sides would page all four rows. + // The inner join matches only ids both objects hold: 3 and 4. + // Reading the first object for both sides pages 1-3; the second, 3-5. const handle = driver.mount(build, { pageSize: 3 }) await handle.flush() - expect(handle.current().data.map((row: any) => row.id)).toEqual( - rows(4) - .filter((row) => row.id === `1` || row.id === `2`) - .map((row) => row.id), - ) + expect(handle.current().data.map((row: any) => row.id)).toEqual([ + `3`, + `4`, + ]) }, ) diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index bb080657bf..2d3afc1e3f 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1291,11 +1291,51 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) // Declared reach: the states where a claim must outlive the query // that made it. - const reach = createOracleReach(`source-id claim memory`, [ + const reach = createOracleReach(`source-id claim memory`, [ `swap after an intervening query`, `swap after a disabled step`, `revisit after an intervening query`, ]) + type ReachState = + | `swap after an intervening query` + | `swap after a disabled step` + | `revisit after an intervening query` + // A state names what lies between the step that made a claim and the + // step that tests it, so a direct swap or a revisit after only a + // disabled step does not count as an intervening query. + const reachedState = ( + history: ReadonlyArray, + next: Pick, + ): ReachState | undefined => { + if (next === `D`) return undefined + const lastIndex = (match: (pick: Pick) => boolean) => { + for (let index = history.length - 1; index >= 0; index--) { + if (match(history[index]!)) return index + } + return -1 + } + const between = (index: number) => history.slice(index + 1) + const conflict = lastIndex( + (prior) => + prior !== `D` && + prior !== next && + objectOf(prior).collection.id === objectOf(next).collection.id, + ) + if (conflict >= 0) { + const steps = between(conflict) + if (steps.some((pick) => pick !== `D`)) { + return `swap after an intervening query` + } + return steps.includes(`D`) + ? `swap after a disabled step` + : undefined + } + const revisit = lastIndex((prior) => prior === next) + return revisit >= 0 && + between(revisit).some((pick) => pick !== `D` && pick !== next) + ? `revisit after an intervening query` + : undefined + } const picks: ReadonlyArray = [`A`, `B`, `C`, `D`] for (const first of [`A`, `C`] as const) { for (const second of picks) { @@ -1314,20 +1354,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { await h.flush() expectUnorderedRows(h.current().data, rowsOf[first]) for (const next of [second, third]) { - const previous = history[history.length - 1]! - if (collides(history, next)) { - reach.reach( - previous === `D` - ? `swap after a disabled step` - : `swap after an intervening query`, - ) - } else if ( - next !== `D` && - next !== previous && - history.includes(next) - ) { - reach.reach(`revisit after an intervening query`) - } + const state = reachedState(history, next) + if (state) reach.reach(state) if (comparesIdentity && collides(history, next)) { await expectRejection( `SourceCollectionIdReuseError`, @@ -1374,8 +1402,13 @@ export function runSuite(rawDriver: LiveQueryDriver) { `source-id-reuse-in-one-query`, `a query that reads two sources under one Collection ID is rejected or reads both`, async () => { + // The objects hold overlapping but different rows, so a binding that + // read either object for both sides would publish another id set. const first = driver.makeSource(SEED) - const reused = driver.makeSource(SEED.slice(0, 2), first.collection.id) + const reused = driver.makeSource( + [...SEED.slice(1), { id: `4`, name: `Fourth`, age: 45, team: `b` }], + first.collection.id, + ) const build = (q: any) => q .from({ left: first.collection }) @@ -1395,9 +1428,9 @@ export function runSuite(rawDriver: LiveQueryDriver) { } const h = driver.mount(build) await h.flush() - // The inner join matches only rows both objects hold, so a binding - // that read one object for both sides would publish all three. - expectUnorderedRows(h.current().data, idsOf(SEED.slice(0, 2))) + // The inner join matches only ids both objects hold: 2 and 3. Reading + // the first object for both sides gives 1-3; the second gives 2-4. + expectUnorderedRows(h.current().data, [{ id: `2` }, { id: `3` }]) h.unmount() }, ) diff --git a/packages/preact-db/tests/useLiveQuery.test.ts b/packages/preact-db/tests/useLiveQuery.test.ts index 6eba766e8d..449e6f45b9 100644 --- a/packages/preact-db/tests/useLiveQuery.test.ts +++ b/packages/preact-db/tests/useLiveQuery.test.ts @@ -139,4 +139,27 @@ describe(`useLiveQuery identity`, () => { expect(rendered).toEqual([`b`]) expect(collection).not.toBe(first) }) + + // A nonempty queryKey owns the identity, so the hook claims no source IDs. + it(`lets a keyed query swap a source under its ID when the key changes`, async () => { + const first = makeSource(`keyed-swap`, [{ id: `a`, team: `x` }]) + const replacement = makeSource(`keyed-swap`, [{ id: `b`, team: `x` }]) + let rendered: Array = [] + function Reader({ source }: { source: typeof first }) { + const result = useLiveQuery({ + queryKey: [`keyed-swap`, source === first ? 1 : 2], + query: (q) => q.from({ row: source }), + }) + rendered = result.data.map((row) => row.id) + return null + } + + const container = mountRoot(h(Reader, { source: first })) + await settle() + expect(rendered).toEqual([`a`]) + + act(() => render(h(Reader, { source: replacement }), container)) + await settle() + expect(rendered).toEqual([`b`]) + }) }) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 8df40ceaa0..64dd061eb8 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -903,33 +903,34 @@ function useLiveQueryImpl( instance.collection = configOrQueryOrCollection instance.config = configOrQueryOrCollection } else { + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + instance.deferredCollections, + ) + } + if (queryKey === undefined && deps !== undefined) { + // A dependency list decides reuse, so check the query whenever the + // hook binds a live query, compiled or taken from the Suspense + // cache: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery( + preparedQueryValue, + getIdentityScope( + instance.sourceIds, + dbClient, + `useLiveQuery`, + instance.deferredCollections, + ), + // Reuse the identity this render derived for hydration. + depsQueryIdentity === unpreparedQueryValue + ? undefined + : () => depsQueryIdentity, + ) + } if (suspenseCollection) { instance.collection = suspenseCollection } else { - if (preparedQueryValue === unpreparedQueryValue) { - preparedQueryValue = prepareQueryValue( - configOrQueryOrCollection, - dbClient, - instance.deferredCollections, - ) - } - if (queryKey === undefined && deps !== undefined) { - // A dependency list decides reuse, so check the query when it - // compiles: it needs a stable identity and keeps its sources' IDs. - identifyLiveQuery( - preparedQueryValue, - getIdentityScope( - instance.sourceIds, - dbClient, - `useLiveQuery`, - instance.deferredCollections, - ), - // Reuse the identity this render derived for hydration. - depsQueryIdentity === unpreparedQueryValue - ? undefined - : () => depsQueryIdentity, - ) - } if (!dbClient) { getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index e09dcd962e..85e8b0e816 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -146,6 +146,23 @@ function mountConfig(build: QueryBuild, opts?: { gcTime?: number }) { return makeHandle(hook) } +// An empty dependency list compiles the query once, at mount, where the hook +// checks its identity and claims its sources. +function mountWithDependencyList(build: QueryBuild) { + const hook = renderHook(() => useLiveQuery(build as any, [])) + return makeHandle(hook) +} + +function mountConfigWithDependencyList( + build: QueryBuild, + opts?: { gcTime?: number }, +) { + const hook = renderHook(() => + useLiveQuery({ query: build as any, ...opts } as any, []), + ) + return makeHandle(hook) +} + function mountDisabled() { // React's disabled convention: the query callback returns null. const hook = renderHook(() => useLiveQuery(() => null as any)) @@ -250,6 +267,8 @@ runSuite(reactDriver) runSuite({ ...reactDriver, name: `react (dependency list)`, + mount: mountWithDependencyList, + mountConfig: mountConfigWithDependencyList, mountControllable: mountControllableWith(`dependency list`), }) it(`publishes a synchronous ordered limit in the first layout commit`, async () => { diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index dee67ed30c..31f5066312 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -139,6 +139,20 @@ function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { ) } +// An empty dependency list compiles the query once, at mount. +function mountWithDependencyList( + build: QueryBuild, + config: InfiniteQueryConfig = {}, +) { + const log: Observations = [] + return makeHandle( + renderHook(() => + useRecorded(useLiveInfiniteQuery(build as any, config as any, []), log), + ), + log, + ) +} + // React compares query identity on two production paths: the identity derived // from the query IR on every render (the 1.0 form), and a deprecated // dependency list. The suite runs once per path. @@ -287,6 +301,7 @@ runInfiniteQuerySuite(reactInfiniteDriver) runInfiniteQuerySuite({ ...reactInfiniteDriver, name: `react (dependency list)`, + mount: mountWithDependencyList, mountControllable: mountControllableWith(`dependency list`), }) 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 16d7480ce3..fddbf7d62d 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -33,8 +33,10 @@ * * Driver and observation: render the public hook, then rerender it with each * source. At the rerender boundary, compare a thrown error or the public row. - * This does not assert a process-wide Collection registry, legacy dependency - * arrays, or explicit-key semantics; explicit queryKey callers own their key. + * A nonempty queryKey owns its identity, so a keyed query skips the claim: a + * same-ID swap under a changed key reads the new source. The release grammar + * below runs dependency-list hook paths too. This does not assert a + * process-wide Collection registry or other explicit-key semantics. */ import { act, render, renderHook, waitFor } from '@testing-library/react' import { @@ -609,6 +611,124 @@ describe(`React source ID reuse`, () => { ) }) + it(`lets a keyed query swap a source under its ID when the key changes`, async () => { + const first = source(`settings`, `first`) + const replacement = source(`settings`, `replacement`) + const hook = renderHook( + ({ current }: { current: typeof first }) => + useLiveQuery({ + queryKey: [`settings`, current === first ? 1 : 2], + query: (q) => q.from({ settings: current }), + }), + { initialProps: { current: first } }, + ) + await waitFor(() => + expect(hook.result.current.data[0]?.value).toBe(`first`), + ) + hook.rerender({ current: replacement }) + await waitFor(() => + expect(hook.result.current.data[0]?.value).toBe(`replacement`), + ) + }) + + // A Suspense mount suspends before it commits, so its retry render may + // reuse the cached Suspense collection instead of compiling again. With a + // dependency list, that retry must still claim the source's ID. + it(`claims a Suspense query's source when a dependency-list retry reuses its cached collection`, async () => { + let markFirstReady!: () => void + const first = createCollection({ + id: `suspense-settings`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `one`, value: `first`, rank: 1 }, + }) + commit() + markFirstReady = markReady + }, + }, + }) + const replacement = source(`suspense-settings`, `replacement`) + const labels = new Map([ + [first, `first`], + [replacement, `replacement`], + ]) + const View = ({ current }: { current: typeof first }) => { + // A hashable dependency keys the Suspense cache; a Collection cannot. + const { data } = useLiveSuspenseQuery( + (q) => q.from({ settings: current }), + [labels.get(current)], + ) + return
{data[0]?.value}
+ } + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + const root = render( + + Loading}> + + + , + ) + expect(root.getByText(`Loading`)).toBeDefined() + await act(async () => markFirstReady()) + await waitFor(() => expect(root.getByText(`first`)).toBeDefined()) + + root.rerender( + + Loading}> + + + , + ) + expect(root.getByText(`Rejected`)).toBeDefined() + } finally { + consoleError.mockRestore() + } + }) + + it(`claims a Suspense query's source when a dependency-list hook mounts from a sibling's cached collection`, async () => { + const first = source(`sibling-suspense`, `first`) + const replacement = source(`sibling-suspense`, `replacement`) + const labels = new Map([ + [first, `first`], + [replacement, `replacement`], + ]) + const View = ({ current }: { current: typeof first }) => { + // A hashable dependency keys the Suspense cache; a Collection cannot. + const { data } = useLiveSuspenseQuery( + (q) => q.from({ settings: current }), + [labels.get(current)], + ) + return
{data[0]?.value}
+ } + const tree = (second: typeof first) => ( + <> + Loading}> + + + + Loading}> + + + + + ) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + const root = render(tree(first)) + await waitFor(() => expect(root.getAllByText(`first`)).toHaveLength(2)) + root.rerender(tree(replacement)) + expect(root.getByText(`Rejected`)).toBeDefined() + } finally { + consoleError.mockRestore() + } + }) + it(`permits independent client scopes to use the same ID`, async () => { const firstClient = new DbClient() const secondClient = new DbClient() diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index b78029b0c6..3947406f2c 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -432,7 +432,7 @@ export function useLiveQuery( let queryHash: string | undefined try { queryHash = - deps.length > 0 && !queryKey + deps.length > 0 && !queryKey?.length ? getStableValueHash( [ `deps`, diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index e344ce50b1..b05c8d77a9 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -9,7 +9,7 @@ * The model has independent source start counts. In the first history, the * first start throws after the Svelte effect asks for rows; the second count * must still become one and the failed effect must release its subscription. - * The release grammar crosses three rejection causes for a client-backed + * The release grammar crosses four rejection causes for a client-backed * query (a descriptor factory that throws during preparation, a query without * a stable IR identity, a query the live query rejects when it compiles, and * a different source object under a Collection ID the mounted hook already @@ -271,10 +271,14 @@ describe(`Svelte descriptor source release`, () => { flushSync() step = { current: replacement, join: true } expect(() => flushSync()).toThrow(SourceCollectionIdReuseError) - // Each field, read on its own, rejects instead of serving the - // previous query's rows or readiness. - for (const field of [`data`, `state`, `status`, `isReady`] as const) { - expect(() => result[field]).toThrow(SourceCollectionIdReuseError) + // Each public field, read on its own, rejects instead of serving the + // previous query's rows or state. + const fields = Object.keys(result) as Array + expect(fields.length).toBeGreaterThanOrEqual(12) + for (const field of fields) { + expect(() => result[field], field).toThrow( + SourceCollectionIdReuseError, + ) } return dispose }, From e8cab0af06e13ebaaa6ed0e5adb4932673c63cec Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:38:56 -0600 Subject: [PATCH 08/17] test: witness the empty-key hydration hash and the same-ID join mutant The previous commit changed Svelte's dependency hash for `queryKey: []` without a witness, and claimed a same-ID join mutant fails without running it. This adds both pieces of evidence. Evidence: - Law: an empty queryKey counts as none (contract.ts); one query over two same-ID objects reads each object (DbClient preload identity oracle). - RED: with the Svelte change reverted, `queryKey: []` plus a dependency list hydrates the server rows while the omitted key hydrates none. - GREEN: svelte-db hydration 2 of 2. - Mutant: resolving each live-query source through the Collection-ID map (last object wins) fails the Vue live and infinite same-ID join scenarios with the overlapping data. - Limits: the hydration witness is a focused receiving witness for DbClient-backed Svelte, not a generated history. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../svelte-db/tests/hydration.svelte.test.ts | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 23b1cbbdac..cd4f2f01cb 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows, so reading either object for both sides fails. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Open decision, owner the maintainer: React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim (trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`). The React oracle states this on purpose for a render that suspends, because its live query may still own the source until cleanup; the abandoned-transition case has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Open decision, owner the maintainer: React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim (trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`). The React oracle states this on purpose for a render that suspends, because its live query may still own the source until cleanup; the abandoned-transition case has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts index 37c37a7534..94a7ed2fbb 100644 --- a/packages/svelte-db/tests/hydration.svelte.test.ts +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -40,4 +40,37 @@ describe(`Svelte hydration`, () => { }) dispose() }) + + // An empty queryKey counts as none, so with a dependency list it must match + // the hydrated query exactly as an omitted key does. + it(`hydrates an empty queryKey with a dependency list as it hydrates no key`, async () => { + const firstRows: Array = [] + for (const queryKey of [undefined, []]) { + const { descriptor } = createPeopleDescriptor() + const serverClient = new DbClient({ runtime: `server` }) + await serverClient.preloadLiveQuery(peopleQuery(descriptor)) + const browserClient = new DbClient({ runtime: `browser` }) + browserClient.hydrate( + serverClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }), + ) + let query!: ReturnType + const dispose = $effect.root(() => { + query = useLiveQuery( + { + ...peopleQuery(descriptor), + client: browserClient, + queryKey, + } as any, + [() => `dependency`], + ) + }) + flushSync() + firstRows.push(query.data) + dispose() + } + expect(firstRows[1]).toEqual(firstRows[0]) + }) }) From 2e36b4797316e1a90e8c09b9635d8ba5cda42845 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:53:24 -0600 Subject: [PATCH 09/17] test: add a hydration control to the empty-queryKey witness The dependency-list cases both miss the server preload, which has no dependency list, so their equality alone would also pass if hydration were broken. A control without a dependency list now shows the harness hydrates the server rows, for an omitted and an empty key. Evidence: RED with the Svelte fix reverted (the empty-key case hydrates the server rows, the omitted key shows none); GREEN 2 of 2. Co-authored-by: Isaac --- .../svelte-db/tests/hydration.svelte.test.ts | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts index 94a7ed2fbb..cfb2a1bdb3 100644 --- a/packages/svelte-db/tests/hydration.svelte.test.ts +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -42,10 +42,15 @@ describe(`Svelte hydration`, () => { }) // An empty queryKey counts as none, so with a dependency list it must match - // the hydrated query exactly as an omitted key does. + // the hydrated query exactly as an omitted key does. The server preload has + // no dependency list, so a query with one does not match it. The control + // case without a dependency list shows this harness does hydrate, so the two + // dependency-list cases agree on a real miss, not on a broken harness. it(`hydrates an empty queryKey with a dependency list as it hydrates no key`, async () => { - const firstRows: Array = [] - for (const queryKey of [undefined, []]) { + const firstRows = async ( + queryKey: Array | undefined, + deps: Array<() => unknown> | undefined, + ) => { const { descriptor } = createPeopleDescriptor() const serverClient = new DbClient({ runtime: `server` }) await serverClient.preloadLiveQuery(peopleQuery(descriptor)) @@ -58,19 +63,27 @@ describe(`Svelte hydration`, () => { ) let query!: ReturnType const dispose = $effect.root(() => { - query = useLiveQuery( - { - ...peopleQuery(descriptor), - client: browserClient, - queryKey, - } as any, - [() => `dependency`], - ) + const config = { + ...peopleQuery(descriptor), + client: browserClient, + queryKey, + } as any + query = deps ? useLiveQuery(config, deps) : useLiveQuery(config) }) flushSync() - firstRows.push(query.data) + const rows = query.data dispose() + return rows } - expect(firstRows[1]).toEqual(firstRows[0]) + const serverRows = [ + expect.objectContaining({ id: `server`, name: `Server snapshot` }), + ] + // Control: the same query and harness hydrate the server rows. + expect(await firstRows(undefined, undefined)).toEqual(serverRows) + expect(await firstRows([], undefined)).toEqual(serverRows) + + const dependency = [() => `dependency`] + const omittedKey = await firstRows(undefined, dependency) + expect(await firstRows([], dependency)).toEqual(omittedKey) }) }) From e7150a5084a417848760fcc991397a468d8a4fa8 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 12:00:05 -0600 Subject: [PATCH 10/17] docs: record the abandoned-render claim as accepted design The maintainer kept the current law: same-ID source objects stay legal, and a render that never commits keeps its source-ID claim, as the React oracle already states for a suspended render. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index cd4f2f01cb..60a15ab98c 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Open decision, owner the maintainer: React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim (trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`). The React oracle states this on purpose for a render that suspends, because its live query may still own the source until cleanup; the abandoned-transition case has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.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. | From ed23c69f12af4a8b554e406ffce045ce24651003 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 12:06:58 -0600 Subject: [PATCH 11/17] chore: tidy leftovers before the next review Drop the deferred-collection field that React's identity scope kept after the render-boundary release, remove a dead field from the Svelte release oracle's causes, update the React changeset for the Suspense cache claim, and record why the Suspense claim rule has fixed histories. Co-authored-by: Isaac --- .changeset/react-db-unhashable-throws.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/react-db/src/useLiveInfiniteQuery.ts | 2 +- packages/react-db/src/useLiveQuery.ts | 32 ++++++------------- ...riptor-query-release-oracle.svelte.test.ts | 9 +----- 5 files changed, 13 insertions(+), 34 deletions(-) diff --git a/.changeset/react-db-unhashable-throws.md b/.changeset/react-db-unhashable-throws.md index 840a6f3d0c..1617f88d2b 100644 --- a/.changeset/react-db-unhashable-throws.md +++ b/.changeset/react-db-unhashable-throws.md @@ -2,6 +2,6 @@ '@tanstack/react-db': minor --- -**Breaking (1.0):** a live query whose IR has no stable identity, such as one using `fn.where`, `fn.select`, or `fn.having`, now throws `UnhashableQueryIRError` unless it supplies a nonempty `queryKey`. It previously warned in development and kept its first compilation. An empty `queryKey` now counts as none, as it already did for a `DbClient` preload, so a hook and a preload agree on the query's identity. A dependency array does not replace `queryKey`: in that mode the hook checks identity and claims source Collection IDs each time the query compiles. A reused source Collection ID now throws `SourceCollectionIdReuseError`. +**Breaking (1.0):** a live query whose IR has no stable identity, such as one using `fn.where`, `fn.select`, or `fn.having`, now throws `UnhashableQueryIRError` unless it supplies a nonempty `queryKey`. It previously warned in development and kept its first compilation. An empty `queryKey` now counts as none, as it already did for a `DbClient` preload, so a hook and a preload agree on the query's identity. A dependency array does not replace `queryKey`: in that mode the hook checks identity and claims source Collection IDs each time it binds a live query, including one that `useLiveSuspenseQuery` takes from its cache. A reused source Collection ID now throws `SourceCollectionIdReuseError`. A rejected render now releases every sync start it deferred for a `DbClient` descriptor, whatever rejected it, so a later hook or `startSyncImmediate()` can start that source. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 60a15ab98c..9e85bb8d25 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 20fc695deb..c395f11ca5 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -173,7 +173,6 @@ export function useLiveInfiniteQuery( (sourceIdsRef.current ??= createLiveQuerySourceIdClaims()), dbClient, `useLiveInfiniteQuery`, - deferredCollections, ) const queryKey = getLiveQueryKey(config) @@ -209,6 +208,7 @@ export function useLiveInfiniteQuery( queryFnOrCollection, derivedIdentityProfilerRef.current, identityScope(), + deferredCollections, ) preparedQueryValue = preparation.value derivedQuery = queryFnOrCollection diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 64dd061eb8..1a78ef8445 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -253,22 +253,16 @@ type DerivedQueryPreparation = { identityDeps: Array } -type HookIdentityScope = LiveQueryIdentityScope & { - deferredCollections: Set> -} - /** How a hook checks a prepared query whose identity it compares. */ export function getIdentityScope( claims: LiveQuerySourceIdClaims | undefined, dbClient: DbClient | undefined, hookName: string, - deferredCollections: Set>, -): HookIdentityScope { +): LiveQueryIdentityScope { return { claims, client: dbClient, bindingName: hookName, - deferredCollections, onUnboundDescriptor: getUnboundDescriptorReporter(dbClient, hookName), } } @@ -276,13 +270,14 @@ export function getIdentityScope( /** * Prepare a query without `queryKey`, derive its identity, and claim its * source Collection IDs. A query whose IR has no stable identity throws - * `UnhashableQueryIRError`; either rejection releases the render's deferred - * sync starts. + * `UnhashableQueryIRError`. The hook's render boundary releases the sync + * starts a rejected render deferred. */ export function prepareDerivedQuery( value: unknown, profiler: DerivedIdentityProfiler, - scope: HookIdentityScope, + scope: LiveQueryIdentityScope, + deferredCollections: Set>, ): DerivedQueryPreparation { const shouldProfile = shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && @@ -291,7 +286,7 @@ export function prepareDerivedQuery( const preparedValue = prepareQueryValue( value, scope.client, - scope.deferredCollections, + deferredCollections, ) try { @@ -789,12 +784,8 @@ function useLiveQueryImpl( const preparation = prepareDerivedQuery( configOrQueryOrCollection, instance.derivedIdentityProfiler, - getIdentityScope( - instance.sourceIds, - dbClient, - `useLiveQuery`, - instance.deferredCollections, - ), + getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`), + instance.deferredCollections, ) preparedQueryValue = preparation.value identityDeps = preparation.identityDeps @@ -916,12 +907,7 @@ function useLiveQueryImpl( // cache: it needs a stable identity and keeps its sources' IDs. identifyLiveQuery( preparedQueryValue, - getIdentityScope( - instance.sourceIds, - dbClient, - `useLiveQuery`, - instance.deferredCollections, - ), + getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`), // Reuse the identity this render derived for hydration. depsQueryIdentity === unpreparedQueryValue ? undefined diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index b05c8d77a9..a75b61c5ef 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -177,10 +177,7 @@ describe(`Svelte descriptor source release`, () => { // rejection that needs an earlier, accepted query. const causes: Record< string, - ( - shared: any, - client: DbClient, - ) => { rejected: Error | string; run: () => () => void } + (shared: any, client: DbClient) => { run: () => () => void } > = { 'a descriptor factory that throws during preparation': (shared, client) => { const failing = collectionOptions(`${shared.id}-failing`, () => { @@ -195,7 +192,6 @@ describe(`Svelte descriptor source release`, () => { .select(({ failing: row }: any) => ({ id: row.id })), ) return { - rejected: `descriptor factory failed`, run: () => { let dispose: (() => void) | undefined expect(() => { @@ -208,7 +204,6 @@ describe(`Svelte descriptor source release`, () => { } }, 'a query without a stable IR identity': (shared, client) => ({ - rejected: `UnhashableQueryIRError`, run: () => { let dispose: (() => void) | undefined expect(() => { @@ -223,7 +218,6 @@ describe(`Svelte descriptor source release`, () => { }, }), 'a query the live query rejects when it compiles': (shared, client) => ({ - rejected: `UnsupportedRootScalarSelectError`, run: () => { let dispose: (() => void) | undefined expect(() => { @@ -241,7 +235,6 @@ describe(`Svelte descriptor source release`, () => { }, }), 'a reused source Collection ID': (shared, client) => ({ - rejected: `SourceCollectionIdReuseError`, run: () => { const first = createCollection({ id: `${shared.id}-settings`, From 1d74e983c05d8acb464b8e55526887461903f54e Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 12:50:24 -0600 Subject: [PATCH 12/17] fix: close the third high review's code findings and generate infinite claims - React: a dependency-list query with a DbClient no longer throws UnhashableQueryIRError on a render whose dependencies did not change; it rejects only when the query compiles, as on main's hydration path (T2). - The infinite suite's source-id-reuse law is an 18-history pairwise grammar with declared reach, replacing one pinned history (T3). - The infinite recompile rejection checks no pages after it (T6). - Contract: comparesQueryIdentity is each driver's declared policy, and Svelte's shared drivers mount no DbClient (T7). rejectLiveQueryInput's doc now states what it covers (T1). - Cleanups: identity claims are required (T8), React reports an unbound descriptor once per render (T9), and React reuses core's LiveQueryKey type (T10). - Coverage map: T1, T4, and T5 recorded as open for a maintainer decision. Evidence: - Law: a comparing hook checks a dependency-list query's identity when it compiles (contract.ts); claims outlive intervening queries (pairwise model in both suites). - Reach: the infinite grammar reaches swap-after-intervening-query and revisit-after-intervening-query; with the C(y) second step removed, reach fails. - RED (ed23c69f1): with a DbClient, flipping to an unhashable query under unchanged dependencies threw UnhashableQueryIRError. - GREEN: react-db 569, preact-db 98, svelte-db 165, vue-db 140, solid-db 103, angular-db 77, db 13790. - Mutant: claims that keep only the latest query fail the infinite grammar for React (both identity paths) and Preact. - Limits: no page-shape or disabled step in the infinite grammar; T1, T4, and T5 remain open in the coverage map. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../react/reference/functions/useLiveQuery.md | 22 +-- docs/framework/react/reference/index.md | 1 - .../ConditionalUseLiveQueryConfig.md | 2 +- .../reference/type-aliases/LiveQueryKey.md | 10 -- .../type-aliases/UseLiveQueryConfig.md | 2 +- .../type-aliases/UseLiveQueryStatus.md | 2 +- docs/reference/functions/getLiveQueryHash.md | 2 +- .../functions/getPreparedLiveQueryIdentity.md | 2 +- .../functions/getPreparedLiveQuerySources.md | 2 +- .../functions/prepareLiveQueryValue.md | 2 +- .../functions/rejectLiveQueryInput.md | 14 +- .../functions/resolveLiveQueryValue.md | 2 +- .../resumeDeferredLiveQueryCollections.md | 2 +- .../interfaces/LiveQueryIdentityScope.md | 6 +- packages/db/src/live-query-adapter.ts | 18 +- packages/db/src/live-query-options.ts | 12 +- packages/db/tests/conformance/contract.ts | 7 +- .../conformance/infinite-suite-oracle.ts | 167 +++++++++++++----- packages/react-db/src/useLiveQuery.ts | 21 ++- packages/react-db/tests/useLiveQuery.test.tsx | 43 +++++ 21 files changed, 236 insertions(+), 105 deletions(-) delete mode 100644 docs/framework/react/reference/type-aliases/LiveQueryKey.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 9e85bb8d25..2d5976ba69 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. A new binding starts unclaimed. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Open, owner the maintainer: (1) a render that throws after a hook returns, such as in a sibling hook, keeps that hook's deferral, so only `preload()` starts the source; the React descriptor binding owner states this preload-only law for renders that never commit. (2) Mixed input forms are not generated: a keyed step records no claim, and the contract does not yet say whether derived A(x), keyed B(x), derived B(x) should reject. (3) The reactive branch checks rows after each swap settles, not every publication; a binding that first republishes A's rows and then corrects within one flush would pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/docs/framework/react/reference/functions/useLiveQuery.md b/docs/framework/react/reference/functions/useLiveQuery.md index 152844beb7..225df385b6 100644 --- a/docs/framework/react/reference/functions/useLiveQuery.md +++ b/docs/framework/react/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:383](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L383) +Defined in: [useLiveQuery.ts:379](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L379) Create a live query using a query function. @@ -196,7 +196,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:403](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L403) +Defined in: [useLiveQuery.ts:399](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L399) Create a live query using a query function. @@ -387,7 +387,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:425](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L425) +Defined in: [useLiveQuery.ts:421](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L421) Create a live query using a query function. @@ -581,7 +581,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:447](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L447) +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. @@ -780,7 +780,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:473](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L473) +Defined in: [useLiveQuery.ts:469](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L469) Create a live query using a query function. @@ -990,7 +990,7 @@ return ( function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:544](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L544) +Defined in: [useLiveQuery.ts:540](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L540) Create a live query using configuration object @@ -1134,7 +1134,7 @@ return
{data.length} items loaded
function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:563](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L563) +Defined in: [useLiveQuery.ts:559](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L559) Create a live query using a query function. @@ -1317,7 +1317,7 @@ return ( function useLiveQuery(config, deps?): object; ``` -Defined in: [useLiveQuery.ts:582](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L582) +Defined in: [useLiveQuery.ts:578](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L578) Create a live query using a query function. @@ -1502,7 +1502,7 @@ return ( function useLiveQuery(config, deps): object; ``` -Defined in: [useLiveQuery.ts:602](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L602) +Defined in: [useLiveQuery.ts:598](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L598) Create a live query using a query function. @@ -1691,7 +1691,7 @@ return ( function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:651](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L651) +Defined in: [useLiveQuery.ts:647](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L647) Subscribe to an existing live query collection @@ -1837,7 +1837,7 @@ return
{data.map(item => )}
function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:674](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L674) +Defined in: [useLiveQuery.ts:670](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L670) Create a live query using a query function. diff --git a/docs/framework/react/reference/index.md b/docs/framework/react/reference/index.md index a136d348f2..272ca66706 100644 --- a/docs/framework/react/reference/index.md +++ b/docs/framework/react/reference/index.md @@ -8,7 +8,6 @@ title: "@tanstack/react-db" - [ConditionalUseLiveQueryConfig](type-aliases/ConditionalUseLiveQueryConfig.md) - [DbProviderProps](type-aliases/DbProviderProps.md) - [HydrationBoundaryProps](type-aliases/HydrationBoundaryProps.md) -- [LiveQueryKey](type-aliases/LiveQueryKey.md) - [UseLiveInfiniteQueryConfig](type-aliases/UseLiveInfiniteQueryConfig.md) - [UseLiveInfiniteQueryReturn](type-aliases/UseLiveInfiniteQueryReturn.md) - [UseLiveInfiniteQueryReturnWithCollection](type-aliases/UseLiveInfiniteQueryReturnWithCollection.md) diff --git a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md index 510e115d38..a1028ff4db 100644 --- a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryConfig type ConditionalUseLiveQueryConfig = UseLiveQueryConfigOptions & object; ``` -Defined in: [useLiveQuery.ts:137](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L137) +Defined in: [useLiveQuery.ts:138](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L138) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/LiveQueryKey.md b/docs/framework/react/reference/type-aliases/LiveQueryKey.md deleted file mode 100644 index 091566ad09..0000000000 --- a/docs/framework/react/reference/type-aliases/LiveQueryKey.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -id: LiveQueryKey -title: LiveQueryKey ---- - -```ts -type LiveQueryKey = ReadonlyArray; -``` - -Defined in: [useLiveQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L113) diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md index 6c417654a4..34499af168 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveQueryConfig type UseLiveQueryConfig = UseLiveQueryConfigOptions & Pick, "query">; ``` -Defined in: [useLiveQuery.ts:133](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L133) +Defined in: [useLiveQuery.ts:134](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L134) ## Type Parameters diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md index c31d9c5f24..878105acea 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md @@ -7,4 +7,4 @@ title: UseLiveQueryStatus type UseLiveQueryStatus = CollectionStatus | "disabled"; ``` -Defined in: [useLiveQuery.ts:112](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L112) +Defined in: [useLiveQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L113) diff --git a/docs/reference/functions/getLiveQueryHash.md b/docs/reference/functions/getLiveQueryHash.md index 62975925c8..28afe0c078 100644 --- a/docs/reference/functions/getLiveQueryHash.md +++ b/docs/reference/functions/getLiveQueryHash.md @@ -7,7 +7,7 @@ title: getLiveQueryHash function getLiveQueryHash(preparedValue, queryKey?): string; ``` -Defined in: [packages/db/src/live-query-options.ts:213](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L213) +Defined in: [packages/db/src/live-query-options.ts:215](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L215) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQueryIdentity.md b/docs/reference/functions/getPreparedLiveQueryIdentity.md index 586d29aea7..d1056ac751 100644 --- a/docs/reference/functions/getPreparedLiveQueryIdentity.md +++ b/docs/reference/functions/getPreparedLiveQueryIdentity.md @@ -7,7 +7,7 @@ title: getPreparedLiveQueryIdentity function getPreparedLiveQueryIdentity(value): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:185](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L185) +Defined in: [packages/db/src/live-query-options.ts:187](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L187) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQuerySources.md b/docs/reference/functions/getPreparedLiveQuerySources.md index 3f4678d9a4..e19f484b05 100644 --- a/docs/reference/functions/getPreparedLiveQuerySources.md +++ b/docs/reference/functions/getPreparedLiveQuerySources.md @@ -7,7 +7,7 @@ title: getPreparedLiveQuerySources function getPreparedLiveQuerySources(value, onUnboundDescriptor?): Collection, string | number, UtilsRecord, StandardSchemaV1, Record>[]; ``` -Defined in: [packages/db/src/live-query-options.ts:162](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L162) +Defined in: [packages/db/src/live-query-options.ts:164](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L164) Concrete source objects in query-position order for a prepared value. diff --git a/docs/reference/functions/prepareLiveQueryValue.md b/docs/reference/functions/prepareLiveQueryValue.md index 17288ae407..1c4db57b15 100644 --- a/docs/reference/functions/prepareLiveQueryValue.md +++ b/docs/reference/functions/prepareLiveQueryValue.md @@ -10,7 +10,7 @@ function prepareLiveQueryValue( deferredCollections?): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:109](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L109) +Defined in: [packages/db/src/live-query-options.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L111) ## Parameters diff --git a/docs/reference/functions/rejectLiveQueryInput.md b/docs/reference/functions/rejectLiveQueryInput.md index 43afa30888..fb6e3f1447 100644 --- a/docs/reference/functions/rejectLiveQueryInput.md +++ b/docs/reference/functions/rejectLiveQueryInput.md @@ -7,14 +7,16 @@ title: rejectLiveQueryInput function rejectLiveQueryInput(collections, rejection): never; ``` -Defined in: [packages/db/src/live-query-options.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L43) +Defined in: [packages/db/src/live-query-options.ts:45](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L45) Release the sync starts a rejected query input deferred, then rethrow its -rejection. A framework binding calls this at its render boundary, so a -render that throws anywhere after preparation leaves no source deferred and -a later reader can start it. As for a `DbClient` preload, the rejection wins -over a source whose resumed start fails; that source records its own -failure. +rejection. A framework hook calls this around its own render work, so a +query the hook rejects leaves no source deferred and a later reader can +start it. A render that throws after the hook returns, such as in a sibling +hook, is not a rejection of this input: like any render that never commits, +its deferral stays until a `preload()` starts the source. As for a +`DbClient` preload, the rejection wins over a source whose resumed start +fails; that source records its own failure. ## Parameters diff --git a/docs/reference/functions/resolveLiveQueryValue.md b/docs/reference/functions/resolveLiveQueryValue.md index 9768b0d8f6..13e83377bf 100644 --- a/docs/reference/functions/resolveLiveQueryValue.md +++ b/docs/reference/functions/resolveLiveQueryValue.md @@ -9,7 +9,7 @@ function resolveLiveQueryValue(value, __namedParameters): | null; ``` -Defined in: [packages/db/src/live-query-options.ts:249](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L249) +Defined in: [packages/db/src/live-query-options.ts:251](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L251) Resolve an adapter's query value to what its observer watches: `null` for a disabled query, an existing Collection with sync started, or a live query. diff --git a/docs/reference/functions/resumeDeferredLiveQueryCollections.md b/docs/reference/functions/resumeDeferredLiveQueryCollections.md index c7d703eb51..62e39a0b27 100644 --- a/docs/reference/functions/resumeDeferredLiveQueryCollections.md +++ b/docs/reference/functions/resumeDeferredLiveQueryCollections.md @@ -7,7 +7,7 @@ title: resumeDeferredLiveQueryCollections function resumeDeferredLiveQueryCollections(collections): void; ``` -Defined in: [packages/db/src/live-query-options.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L56) +Defined in: [packages/db/src/live-query-options.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L58) Release every deferred source, then report the first startup failure. diff --git a/docs/reference/interfaces/LiveQueryIdentityScope.md b/docs/reference/interfaces/LiveQueryIdentityScope.md index 39b62b82aa..1cbd51232e 100644 --- a/docs/reference/interfaces/LiveQueryIdentityScope.md +++ b/docs/reference/interfaces/LiveQueryIdentityScope.md @@ -19,15 +19,15 @@ Defined in: [packages/db/src/live-query-adapter.ts:124](https://github.com/TanSt *** -### claims? +### claims ```ts -optional claims: LiveQuerySourceIdClaims; +claims: LiveQuerySourceIdClaims; ``` Defined in: [packages/db/src/live-query-adapter.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L122) -This binding's claims; omit to check identity without claiming. +This binding's source Collection ID claims. *** diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index b80eb6b69a..3cb91a2996 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -118,8 +118,8 @@ export function claimLiveQuerySourceIds( /** Where a binding that compares query identity checks a prepared query. */ export interface LiveQueryIdentityScope { - /** This binding's claims; omit to check identity without claiming. */ - claims?: LiveQuerySourceIdClaims + /** This binding's source Collection ID claims. */ + claims: LiveQuerySourceIdClaims client: DbClient | undefined bindingName: string /** Report a descriptor source that no client bound. */ @@ -143,13 +143,11 @@ export function identifyLiveQuery( ) => TIdentity, ): TIdentity { const identity = identify(preparedValue) - if (scope.claims) { - claimLiveQuerySourceIds( - scope.claims, - getPreparedLiveQuerySources(preparedValue, scope.onUnboundDescriptor), - scope.client, - scope.bindingName, - ) - } + claimLiveQuerySourceIds( + scope.claims, + getPreparedLiveQuerySources(preparedValue, scope.onUnboundDescriptor), + scope.client, + scope.bindingName, + ) return identity } diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index d335438703..8813392b47 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -34,11 +34,13 @@ export type DeferredLiveQueryCollections = Set< /** * Release the sync starts a rejected query input deferred, then rethrow its - * rejection. A framework binding calls this at its render boundary, so a - * render that throws anywhere after preparation leaves no source deferred and - * a later reader can start it. As for a `DbClient` preload, the rejection wins - * over a source whose resumed start fails; that source records its own - * failure. + * rejection. A framework hook calls this around its own render work, so a + * query the hook rejects leaves no source deferred and a later reader can + * start it. A render that throws after the hook returns, such as in a sibling + * hook, is not a rejection of this input: like any render that never commits, + * its deferral stays until a `preload()` starts the source. As for a + * `DbClient` preload, the rejection wins over a source whose resumed start + * fails; that source records its own failure. */ export function rejectLiveQueryInput( collections: DeferredLiveQueryCollections | undefined, diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 767f510e99..cdd7dcd929 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -247,8 +247,11 @@ export interface LiveQueryDriver { mountDisabled: () => LiveQueryHandle /** * Whether the binding compares query identity to decide when to compile - * again (see the opening query-identity law). A public adapter property, not - * inferred from observed output. + * again (see the opening query-identity law). The driver declares the + * adapter's documented policy for the inputs it mounts; the suite does not + * infer it from observed output. Svelte compares identity only for a query + * that a `DbClient` backs, and its shared drivers mount none, so they + * declare `false`. */ comparesQueryIdentity: boolean /** No active waivers. Kept empty in drivers to make this boundary explicit. */ diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index d4d68986bf..cee5b58e9e 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -16,6 +16,7 @@ * history instead of test-runner housekeeping. */ import { describe, expect, it, vi } from 'vitest' +import { createOracleReach } from '../oracle-reach' import { expectPageRows } from './page-laws-oracle' import { ScenarioLifetime } from './scenario-lifetime' import { ScenarioSources } from './scenario-sources' @@ -159,6 +160,24 @@ async function captureRejection( } } +/** + * After a rejection, every read throws or holds no rows. Two reads catch a + * memo that throws once and then serves its previous value. No current driver + * can fail this check (see `contract.ts`); it guards a driver whose binding + * stays mounted after a rejection. + */ +function expectNoPagesAfterRejection(handle: InfiniteQueryHandle): void { + for (let read = 0; read < 2; read++) { + let data: unknown + try { + data = handle.current().data + } catch { + continue + } + expect(data).toEqual([]) + } +} + async function waitFor(check: () => boolean): Promise { for (let attempt = 0; attempt < 20; attempt++) { if (check()) return @@ -216,6 +235,8 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { key: string, name: string, fn: () => Promise | void, + // A generated grammar runs many histories, each through the driver's cut. + timeoutMs?: number, ) => { registry.register(key) const label = `[${key}] ${name}` @@ -237,7 +258,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { sources = null } } - it(label, run) + it(label, run, timeoutMs) } describe(`infinite-query conformance :: ${driver.name}`, () => { @@ -1717,52 +1738,113 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { `source-id-reuse`, `rejects or pages a different source under a Collection ID the binding read`, async () => { + // Claim memory grammar, as in the live suite without a disabled step: + // a history mounts on A(x) or C(y), then takes two steps over A(x), + // B(x) (another object under A's ID), and C(y). The pairwise model + // judges each step from the history alone. A comparing binding rejects + // a step when an earlier step read another object under its ID, which + // ends the history; a reactive binding pages the selected source. + type Pick = `A` | `B` | `C` const first = driver.makeSource(rows(4)) - const other = driver.makeSource(rows(2, `o`)) - const reused = driver.makeSource(rows(3, `r`), first.collection.id) - const handle = driver.mountControllable( - (q, source: { collection: any }) => - q - .from({ items: source.collection }) - .orderBy(({ items }: any) => items.rank, `desc`), - first, - { pageSize: 3 }, - ) - await handle.flush() - expectPageRows(handle.current(), rows(4).slice(0, 3), 3) - // A fresh ID and a revisit of the claimed object are legal. - handle.setParamSync(other) - await handle.flush() - expectPageRows(handle.current(), rows(2, `o`), 3) - handle.setParamSync(first) - await handle.flush() - expectPageRows(handle.current(), rows(4).slice(0, 3), 3) - handle.setParamSync(other) - await handle.flush() - if (!driver.comparesQueryIdentity) { - handle.setParamSync(reused) - await handle.flush() - expectPageRows(handle.current(), rows(3, `r`), 3) - return + const sourceOf: Record = { + A: first, + B: driver.makeSource(rows(3, `r`), first.collection.id), + C: driver.makeSource(rows(2, `o`)), } - const error = await captureRejection(async () => { - handle.setParamSync(reused) - await handle.flush() - return handle.current() - }) - expect(error).toMatchObject({ name: `SourceCollectionIdReuseError` }) - // After a rejection, every read throws or holds no rows. Two reads - // catch a memo that throws once and then serves its previous value. - for (let read = 0; read < 2; read++) { - let data: unknown - try { - data = handle.current().data - } catch { - continue + const pagesOf: Record> = { + A: rows(4).slice(0, 3), + B: rows(3, `r`), + C: rows(2, `o`), + } + const collides = (history: ReadonlyArray, next: Pick) => + history.some( + (prior) => + prior !== next && + sourceOf[prior].collection.id === sourceOf[next].collection.id, + ) + type ReachState = + | `swap after an intervening query` + | `revisit after an intervening query` + // A state names what lies between the step that made a claim and the + // step that tests it. + const reachedState = ( + history: ReadonlyArray, + next: Pick, + ): ReachState | undefined => { + const lastIndex = (match: (pick: Pick) => boolean) => { + for (let index = history.length - 1; index >= 0; index--) { + if (match(history[index]!)) return index + } + return -1 + } + const conflict = lastIndex( + (prior) => + prior !== next && + sourceOf[prior].collection.id === sourceOf[next].collection.id, + ) + if (conflict >= 0) { + return history.length - conflict > 1 + ? `swap after an intervening query` + : undefined + } + const revisit = lastIndex((prior) => prior === next) + return revisit >= 0 && history.length - revisit > 1 + ? `revisit after an intervening query` + : undefined + } + // Declared reach: the states where a claim must outlive the query + // that made it. + const reach = createOracleReach( + `infinite source-id claim memory`, + [ + `swap after an intervening query`, + `revisit after an intervening query`, + ], + ) + const picks: ReadonlyArray = [`A`, `B`, `C`] + for (const start of [`A`, `C`] as const) { + for (const second of picks) { + for (const third of picks) { + reach.beginHistory() + const history: Array = [start] + const handle = driver.mountControllable( + (q, pick: Pick) => + q + .from({ items: sourceOf[pick].collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + start as Pick, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), pagesOf[start], 3) + for (const next of [second, third]) { + const state = reachedState(history, next) + if (state) reach.reach(state) + if (driver.comparesQueryIdentity && collides(history, next)) { + const error = await captureRejection(async () => { + handle.setParamSync(next) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ + name: `SourceCollectionIdReuseError`, + }) + expectNoPagesAfterRejection(handle) + break + } + handle.setParamSync(next) + await handle.flush() + history.push(next) + expectPageRows(handle.current(), pagesOf[next], 3) + } + handle.unmount() + } } - expect(data).toEqual([]) } + reach.assertReached() }, + // 18 histories of three steps each. + 30_000, ) scenario( @@ -1793,6 +1875,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { return handle.current() }) expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + expectNoPagesAfterRejection(handle) return } // A changed captured value recompiles, though no identity names it. diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 1a78ef8445..3c21030e5f 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -35,6 +35,7 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, LiveQueryIdentityScope, + LiveQueryKey, LiveQueryObserver, LiveQueryPersistedStatus, LiveQuerySourceIdClaims, @@ -110,7 +111,7 @@ export type DerivedIdentityProfiler = { } export type UseLiveQueryStatus = CollectionStatus | `disabled` -export type LiveQueryKey = ReadonlyArray +export type { LiveQueryKey } type UseLiveQueryConfigOptions = Omit< LiveQueryCollectionConfig, `query` @@ -255,7 +256,7 @@ type DerivedQueryPreparation = { /** How a hook checks a prepared query whose identity it compares. */ export function getIdentityScope( - claims: LiveQuerySourceIdClaims | undefined, + claims: LiveQuerySourceIdClaims, dbClient: DbClient | undefined, hookName: string, ): LiveQueryIdentityScope { @@ -774,8 +775,16 @@ function useLiveQueryImpl( dbClient, instance.deferredCollections, ) - depsQueryIdentity = getPreparedLiveQueryIdentity(preparedQueryValue) - streamIdentity = [`deps`, resolvedDeps, depsQueryIdentity] + try { + depsQueryIdentity = getPreparedLiveQueryIdentity(preparedQueryValue) + streamIdentity = [`deps`, resolvedDeps, depsQueryIdentity] + } catch (error) { + // The dependency list, not this hash, decides reuse: an unhashable + // query leaves hydration without a key and rejects only when it + // compiles. + if (!(error instanceof UnhashableQueryIRError)) throw error + identityError = error + } } } else if (inputIsCollection) { identityDeps = [] @@ -917,7 +926,9 @@ function useLiveQueryImpl( if (suspenseCollection) { instance.collection = suspenseCollection } else { - if (!dbClient) { + // A keyed query claims nothing, so it reports an unbound descriptor + // here; the other forms reported it when they claimed. + if (!dbClient && queryKey !== undefined) { getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) } instance.collection = resolveLiveQueryValue(preparedQueryValue, { diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 74af321b88..4712782491 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -3286,6 +3286,49 @@ describe(`Query Collections`, () => { ).toThrow(UnhashableQueryIRError) }) + // A dependency list decides reuse, so the identity is checked when the + // query compiles: at mount and when a dependency changes. With a + // DbClient, the hook also hashes the query for hydration on every render; + // an unhashable query there leaves hydration without a key until the + // query compiles again. + it(`rejects an unhashable dependency-list query with a DbClient only when it compiles`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `deps-client-unhashable`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const client = new DbClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const hook = renderHook( + ({ functional, version }) => + useLiveQuery( + (q) => + functional + ? q + .from({ people: collection }) + .fn.where(({ people }) => people.age > 25) + : q.from({ people: collection }), + [version], + ), + { initialProps: { functional: false, version: 1 }, wrapper }, + ) + await waitFor(() => expect(hook.result.current.data).toHaveLength(3)) + + // The query changed but its dependency did not, so nothing compiles. + expect(() => + hook.rerender({ functional: true, version: 1 }), + ).not.toThrow() + expect(hook.result.current.data).toHaveLength(3) + + expect(() => hook.rerender({ functional: true, version: 2 })).toThrow( + UnhashableQueryIRError, + ) + }) + it(`uses runtime identity for opaque values in a structured query without queryKey`, () => { const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) const collection = createCollection( From bb07231c241750f017cf18360e5d468b98bd1cd5 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 14:31:40 -0600 Subject: [PATCH 13/17] fix: release a render's deferred sync starts when its live query is reclaimed A React render that never commits, because a sibling threw after the hook returned or the render suspended, and a Svelte setup that throws after the hook returned, kept their DbClient sync-start deferrals forever: only preload() could start the source. Each hook now releases the starts it deferred when garbage collection reclaims the live query it built. Evidence: - Law: a render that never commits releases the sync starts it deferred, so a direct reader's demand starts the source (maintainer decision 2026-10-23, widening the descriptor binding owner's preload-only law). - Reach: every new history asserts the render materialized the shared descriptor before the reader. - RED (1d74e983c): 20 React cells (sibling throw and suspend across live, infinite, and Suspense paths) and 4 Svelte cells fail: the reader cannot start the source. - GREEN: react-db 585, svelte-db 171. - Mutant: removing the reclaim release fails exactly 8 cells per React hook and 4 Svelte cells. Releasing at the end of render instead fails the descriptor binding oracle's no-work-before-commit law. - Limits: a Suspense hook that returns has preloaded its query, so those cells do not apply; the release waits for the reclaim, so a held commit past the GC floor releases before it commits. Co-authored-by: Isaac --- .changeset/db-query-identity-laws.md | 2 +- .changeset/react-db-unhashable-throws.md | 2 +- .changeset/svelte-db-query-identity.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/live-query-adapter.ts | 1 + packages/db/src/live-query-options.ts | 24 +++++++ packages/react-db/src/useLiveInfiniteQuery.ts | 4 ++ packages/react-db/src/useLiveQuery.ts | 9 +++ .../tests/source-id-reuse-oracle.test.tsx | 67 +++++++++++++++++-- packages/svelte-db/src/useLiveQuery.svelte.ts | 4 ++ ...riptor-query-release-oracle.svelte.test.ts | 48 ++++++++++--- 11 files changed, 146 insertions(+), 19 deletions(-) diff --git a/.changeset/db-query-identity-laws.md b/.changeset/db-query-identity-laws.md index 5408fab225..8020f09d16 100644 --- a/.changeset/db-query-identity-laws.md +++ b/.changeset/db-query-identity-laws.md @@ -6,4 +6,4 @@ Collect the helpers the framework adapters share into one adapter layer, exporte Add `SourceCollectionIdReuseError`, which a mounted hook that compares query identity throws when a different source Collection appears under a Collection ID it already read. -Add `rejectLiveQueryInput`, which a framework hook calls at its render boundary. It releases the sync starts that a rejected render deferred and rethrows the rejection. Each released source follows its own start policy, so `startSyncImmediate()` or another hook can still start it. +Add `rejectLiveQueryInput` and `releaseDeferredOnReclaim` for framework hooks. The first releases the sync starts a rejected query deferred and rethrows the rejection. The second releases a render's deferred sync starts when garbage collection reclaims the live query it built, so a render that never commits, because a sibling threw or it suspended, does not keep its sources deferred. Each released source follows its own start policy, so `startSyncImmediate()` or another hook can still start it. diff --git a/.changeset/react-db-unhashable-throws.md b/.changeset/react-db-unhashable-throws.md index 1617f88d2b..d5782ecf19 100644 --- a/.changeset/react-db-unhashable-throws.md +++ b/.changeset/react-db-unhashable-throws.md @@ -4,4 +4,4 @@ **Breaking (1.0):** a live query whose IR has no stable identity, such as one using `fn.where`, `fn.select`, or `fn.having`, now throws `UnhashableQueryIRError` unless it supplies a nonempty `queryKey`. It previously warned in development and kept its first compilation. An empty `queryKey` now counts as none, as it already did for a `DbClient` preload, so a hook and a preload agree on the query's identity. A dependency array does not replace `queryKey`: in that mode the hook checks identity and claims source Collection IDs each time it binds a live query, including one that `useLiveSuspenseQuery` takes from its cache. A reused source Collection ID now throws `SourceCollectionIdReuseError`. -A rejected render now releases every sync start it deferred for a `DbClient` descriptor, whatever rejected it, so a later hook or `startSyncImmediate()` can start that source. +A rejected render now releases every sync start it deferred for a `DbClient` descriptor, so a later hook or `startSyncImmediate()` can start that source. A render that never commits, because a sibling threw or it suspended, releases them when its live query is reclaimed. diff --git a/.changeset/svelte-db-query-identity.md b/.changeset/svelte-db-query-identity.md index d3fae99c2e..e60be451c1 100644 --- a/.changeset/svelte-db-query-identity.md +++ b/.changeset/svelte-db-query-identity.md @@ -4,4 +4,4 @@ **Breaking (1.0):** a `useLiveQuery` backed by a `DbClient` matches queries by identity hash, so it now throws `UnhashableQueryIRError` for a query without a stable IR identity unless it supplies a nonempty `queryKey`. It previously compiled the query without a hydration key. While such a hook is mounted, a different source Collection under a Collection ID it already read throws `SourceCollectionIdReuseError`, and every result property then throws instead of returning the previous query's state. Queries without a `DbClient` are unchanged. -A rejected client-backed query now releases every sync start it deferred, so `startSyncImmediate()` or another hook can still start that source. `useLiveInfiniteQuery` now uses the shared window binding from `@tanstack/db`. +A rejected client-backed query now releases every sync start it deferred, so `startSyncImmediate()` or another hook can still start that source. A setup that throws after the hook returns releases them when its live query is reclaimed. `useLiveInfiniteQuery` now uses the shared window binding from `@tanstack/db`. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 2d5976ba69..341893eb87 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. Open, owner the maintainer: (1) a render that throws after a hook returns, such as in a sibling hook, keeps that hook's deferral, so only `preload()` starts the source; the React descriptor binding owner states this preload-only law for renders that never commit. (2) Mixed input forms are not generated: a keyed step records no claim, and the contract does not yet say whether derived A(x), keyed B(x), derived B(x) should reject. (3) The reactive branch checks rows after each swap settles, not every publication; a binding that first republishes A's rows and then corrects within one flush would pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Open, owner the maintainer: (2) Mixed input forms are not generated: a keyed step records no claim, and the contract does not yet say whether derived A(x), keyed B(x), derived B(x) should reject. (3) The reactive branch checks rows after each swap settles, not every publication; a binding that first republishes A's rows and then corrects within one flush would pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index 3cb91a2996..9fffe4bbd2 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -27,6 +27,7 @@ export { getPreparedLiveQuerySources, prepareLiveQueryValue, rejectLiveQueryInput, + releaseDeferredOnReclaim, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from './live-query-options.js' diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 8813392b47..522f4b9298 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -54,6 +54,30 @@ export function rejectLiveQueryInput( throw rejection } +/** + * Release a render's deferred sync starts when the live query it built is + * reclaimed. A render that commits releases them first, when it subscribes. + * A render that never commits, because a sibling threw or it suspended, leaves + * its live query unsubscribed until garbage collection cleans it up; that + * cleanup releases the starts, so a direct reader's demand, made before or + * after, then starts the sources. Releases only the starts deferred so far, + * not ones a later render of the same hook defers. + */ +export function releaseDeferredOnReclaim( + liveQuery: Pick, `once`>, + deferredCollections: DeferredLiveQueryCollections, +): void { + if (deferredCollections.size === 0) return + const pending: DeferredLiveQueryCollections = new Set(deferredCollections) + liveQuery.once(`status:cleaned-up`, () => { + try { + resumeDeferredLiveQueryCollections(pending) + } catch { + // A source whose resumed start fails records its own failure. + } + }) +} + /** Release every deferred source, then report the first startup failure. */ export function resumeDeferredLiveQueryCollections( collections: DeferredLiveQueryCollections, diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index c395f11ca5..f98db21749 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -10,6 +10,7 @@ import { isCollection, readLiveQueryWindowPageShape, rejectLiveQueryInput, + releaseDeferredOnReclaim, resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' @@ -271,6 +272,9 @@ export function useLiveInfiniteQuery( if (binding.supplied) renderedRef.current = null else if (newCollection && binding.ownsCollection) { renderedRef.current = currentRenderState + // A render that never commits releases its deferred sync starts when + // garbage collection reclaims the window collection it built. + releaseDeferredOnReclaim(binding.collection, deferredCollections) } } const renderState = currentRenderState! diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 3c21030e5f..cb24b3c527 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -14,6 +14,7 @@ import { isCollection, prepareLiveQueryValue, rejectLiveQueryInput, + releaseDeferredOnReclaim, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from '@tanstack/db' @@ -975,6 +976,14 @@ function useLiveQueryImpl( suspenseCollections.set(suspenseKey, entry) } } + // A render that never commits releases its deferred sync starts when + // garbage collection reclaims the live query it bound. + if (instance.collection) { + releaseDeferredOnReclaim( + instance.collection, + instance.deferredCollections, + ) + } instance.config = configOrQueryOrCollection instance.deps = [...identityDeps] } 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 fddbf7d62d..da3d4c65a0 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -21,7 +21,9 @@ * collision, a query without a stable IR identity, a descriptor factory that * throws while the query binds its sources, a query the live query rejects * when it compiles, a single-result query in an infinite hook, which pages - * many rows, and a Suspense query that cannot stream during SSR), every hook + * many rows, a Suspense query that cannot stream during SSR, a sibling that + * throws after the hook returns, and a render that suspends and never + * commits), every hook * path (live, infinite, and Suspense, each with derived identity or a * dependency list), and both direct * readers (another hook over the Collection, and `startSyncImmediate()`). The @@ -140,6 +142,12 @@ const releaseCauses: Record< setup?: (client: DbClient) => void /** Render the rejected step first, for a cause only a mount reaches. */ atMount?: boolean + /** + * What the component does after the hook returns in the rejected step: + * throw to an error boundary, or suspend on a promise that never settles. + * Either way the render never commits; the hook itself rejected nothing. + */ + afterHook?: `throw` | `suspend` } > = { 'a source ID collision': (shared) => ({ @@ -238,6 +246,41 @@ const releaseCauses: Record< rejectsIn: (hook) => hook.suspense && hook.usesDeps, atMount: true, }), + 'a sibling that throws after the hook returns': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + afterHook: `throw`, + // A Suspense hook that returns has already preloaded its query, which + // starts its sources, so no deferral is left to release. + rejectsIn: (hook) => !hook.suspense, + }), + 'a render that suspends and never commits': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + afterHook: `suspend`, + // As above: a Suspense hook's own read is a preload. + rejectsIn: (hook) => !hook.suspense, + }), } function orderIf(query: any, ordered: boolean) { @@ -291,19 +334,22 @@ const releaseHooks: Record = { }, } +// Each reader asks for the source once and returns a read of its row. A +// render that never commits releases its deferral when its live query is +// reclaimed, so the row may arrive after the reader asked. const releaseReaders: Record< string, - (shared: ReturnType) => Promise + (shared: ReturnType) => Promise<() => unknown> > = { 'another hook over the Collection': async (shared) => { const reader = renderHook(() => useLiveQuery(shared)) await act(async () => {}) - return reader.result.current.data[0]?.value + return () => reader.result.current.data[0]?.value }, 'startSyncImmediate()': async (shared) => { shared.startSyncImmediate() await act(async () => {}) - return shared.toArray[0]?.value + return () => shared.toArray[0]?.value }, } @@ -346,11 +392,16 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, }) const cause = makeCause(shared, failing) cause.setup?.(client) + const never = new Promise(() => {}) function View({ step }: { step: ReleaseStep }) { hook.use( (q) => cause.query(q, step, hook.ordered), cause.deps?.(step) ?? [step.current, step.reject], ) + if (step.reject && cause.afterHook === `throw`) { + throw new Error(`sibling failed`) + } + if (step.reject && cause.afterHook === `suspend`) throw never return
Query
} const tree = (step: ReleaseStep) => ( @@ -375,14 +426,18 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, ) await act(async () => {}) if (!cause.atMount) root.rerender(tree(cause.rejected(initial))) - expect(root.getByText(`Rejected`)).toBeDefined() + expect( + root.getByText( + cause.afterHook === `suspend` ? `Loading` : `Rejected`, + ), + ).toBeDefined() // Declared reach: the rejected render materialized the shared // descriptor, so it held a deferral the law must release. expect(materialized).toBe(1) expect(syncStarts).toBe(0) const value = await read(client.collection(shared) as any) - expect(value).toBe(`shared`) + await waitFor(() => expect(value()).toBe(`shared`)) expect(syncStarts).toBe(1) } finally { consoleError.mockRestore() diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 3947406f2c..021d0b2c78 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -14,6 +14,7 @@ import { isSingleResultCollection, prepareLiveQueryValue, rejectLiveQueryInput, + releaseDeferredOnReclaim, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from '@tanstack/db' @@ -463,6 +464,9 @@ export function useLiveQuery( }) } + // A setup that never subscribes releases its deferred sync starts when + // garbage collection reclaims the live query it built. + if (collection) releaseDeferredOnReclaim(collection, deferredCollections) return { collection, client: dbClient, diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index a75b61c5ef..7eaef7d9da 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -9,11 +9,11 @@ * The model has independent source start counts. In the first history, the * first start throws after the Svelte effect asks for rows; the second count * must still become one and the failed effect must release its subscription. - * The release grammar crosses four rejection causes for a client-backed + * The release grammar crosses five causes for a client-backed * query (a descriptor factory that throws during preparation, a query without - * a stable IR identity, a query the live query rejects when it compiles, and - * a different source object under a Collection ID the mounted hook already - * read), two source start policies, and three direct + * a stable IR identity, a query the live query rejects when it compiles, a + * setup that throws after the hook returns, and a different source object + * under a Collection ID the mounted hook already read), two source start policies, and three direct * readers (`preload()`, `startSyncImmediate()`, and another hook). After the * rejection, an eager (`startSync: true`) source has started once and a lazy * source has not started; after the reader, each has started exactly once @@ -34,7 +34,7 @@ * query ran the shared descriptor's factory once before the reader. A history * that rejects before it binds the shared source therefore fails. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { DbClient, Query, @@ -177,7 +177,14 @@ describe(`Svelte descriptor source release`, () => { // rejection that needs an earlier, accepted query. const causes: Record< string, - (shared: any, client: DbClient) => { run: () => () => void } + ( + shared: any, + client: DbClient, + ) => { + run: () => () => void + /** The hook never rejected; its deferral waits for the reclaim. */ + releasedOnReclaim?: boolean + } > = { 'a descriptor factory that throws during preparation': (shared, client) => { const failing = collectionOptions(`${shared.id}-failing`, () => { @@ -234,6 +241,21 @@ describe(`Svelte descriptor source release`, () => { return () => dispose?.() }, }), + 'a setup that throws after the hook returns': (shared, client) => ({ + run: () => { + let dispose: (() => void) | undefined + // The hook rejected nothing; later setup code failed, so the + // subscribing effect never runs. + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ client, query: new Query().from({ shared }) }) + throw new Error(`setup failed`) + }) + }).toThrow(`setup failed`) + return () => dispose?.() + }, + releasedOnReclaim: true, + }), 'a reused source Collection ID': (shared, client) => ({ run: () => { const first = createCollection({ @@ -325,15 +347,23 @@ describe(`Svelte descriptor source release`, () => { const client = new DbClient() let dispose: (() => void) | undefined try { - dispose = makeCause(shared, client).run() + const cause = makeCause(shared, client) + dispose = cause.run() // Declared reach: the rejected query materialized the shared // descriptor, so it held a deferral the law must release. expect(materialized).toBe(1) - expect(starts).toBe(policy === `eager` ? 1 : 0) + // A rejection releases at once, so an eager source starts now. A + // setup that never subscribes releases at the reclaim, so nothing + // has started yet. + expect(starts).toBe( + policy === `eager` && !cause.releasedOnReclaim ? 1 : 0, + ) const direct = client.collection(shared) await read(direct) + // A setup that never subscribes releases its deferral when its + // live query is reclaimed, which may follow the reader's demand. + await vi.waitFor(() => expect(direct.status).toBe(`ready`)) expect(starts).toBe(1) - expect(direct.status).toBe(`ready`) } finally { dispose?.() await client.cleanup() From 5bef3b71751daecd5b902d579c45201b39aa606c Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 14:41:47 -0600 Subject: [PATCH 14/17] test: generate keyed steps in the source-ID claim grammar A nonempty queryKey owns its identity, so a keyed step records no claim and checks none, and a claim made before it still binds a later derived step (maintainer decision 2026-10-23). The claim grammar now crosses keyed and derived steps for every binding that compares identity, through a keyed controllable handle in the React (both identity paths) and Preact drivers. Evidence: - Law: contract.ts query-identity law, keyed-step rule. - Reach: 108 keyed histories reach "claim crosses a keyed step" and "keyed step after a claim"; reach labels look between the claiming step and the step it binds, and with no keyed second step the first state is not reached and the check fails. - RED: not applicable; production already follows the rule. The grammar is calibrated by mutants instead. - GREEN: React 177, Preact 86 conformance tests; the reactive suites pass. - Mutant: a keyed step that claims (a key reader that ignores keys), and a keyed step that resets earlier claims, each fail the grammar for React on both paths and for Preact. - Limits: reactive bindings have no keys, so the keyed grammar runs only for comparing drivers. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/tests/conformance/contract.ts | 14 +- packages/db/tests/conformance/suite.ts | 137 ++++++++++++++++++- packages/preact-db/tests/conformance.test.ts | 26 ++++ packages/react-db/tests/conformance.test.tsx | 37 +++++ 5 files changed, 212 insertions(+), 4 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 341893eb87..c03dd1b4e7 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Open, owner the maintainer: (2) Mixed input forms are not generated: a keyed step records no claim, and the contract does not yet say whether derived A(x), keyed B(x), derived B(x) should reject. (3) The reactive branch checks rows after each swap settles, not every publication; a binding that first republishes A's rows and then corrects within one flush would pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Open, owner the maintainer: (3) The reactive branch checks rows after each swap settles, not every publication; a binding that first republishes A's rows and then corrects within one flush would pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index cdd7dcd929..2acdcba723 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -33,7 +33,9 @@ * `SourceCollectionIdReuseError` before any row from either source is * returned. Revisiting the same object is legal, and a new binding starts with * no claims. An input with a nonempty `queryKey` owns its identity and skips - * both checks; an empty key is absent, as it is for a `DbClient` preload. + * both checks: a keyed step records no claim and checks none, so a claim + * made before it still binds a later derived step (maintainer decision + * 2026-10-23). An empty key is absent, as it is for a `DbClient` preload. * * A binding that compiles again whenever its reactive reads change (Vue, * Solid, Angular, and Svelte without a `DbClient`) compares no identity. It @@ -236,6 +238,16 @@ export interface LiveQueryDriver { build: (q: any, param: P) => any, initial: P, ) => ControllableHandle

+ /** + * Mount a controllable query in the config-object form whose `queryKey` is + * `key(param)`, or none when that returns `undefined`. Every driver that + * compares query identity supplies it; a reactive binding has no keys. + */ + mountKeyedControllable?:

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + ) => ControllableHandle

/** Mount a pre-created collection passed directly to the hook. */ mountCollection: (collection: Collection) => LiveQueryHandle /** Mount via the config-object input form (`{ query: build }`). */ diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 2d3afc1e3f..50f24ad301 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1336,6 +1336,136 @@ export function runSuite(rawDriver: LiveQueryDriver) { ? `revisit after an intervening query` : undefined } + // Keyed dimension (comparing bindings only). A step may name its + // source through a nonempty queryKey: AK reads A(x) and BK reads B(x) + // under their own keys. A keyed step records no claim and checks + // none, so a claim made before it still binds a later derived step. + type KeyedPick = Pick | `AK` | `BK` + const objectOfKeyed = (pick: Exclude) => + pick === `AK` + ? objectOf(`A`) + : pick === `BK` + ? objectOf(`B`) + : objectOf(pick) + const rowsOfKeyed = (pick: Exclude) => + rowsOf[pick === `AK` ? `A` : pick === `BK` ? `B` : pick] + const isKeyed = (pick: KeyedPick) => pick === `AK` || pick === `BK` + const keyedCollides = ( + history: ReadonlyArray, + next: KeyedPick, + ) => + next !== `D` && + !isKeyed(next) && + history.some( + (prior) => + prior !== `D` && + !isKeyed(prior) && + objectOfKeyed(prior) !== objectOfKeyed(next) && + objectOfKeyed(prior).collection.id === + objectOfKeyed(next).collection.id, + ) + type KeyedReachState = + `claim crosses a keyed step` | `keyed step after a claim` + const keyedReach = createOracleReach( + `source-id claim memory with keys`, + [`claim crosses a keyed step`, `keyed step after a claim`], + ) + const runKeyedClaimGrammar = async () => { + const mountKeyed = driver.mountKeyedControllable + // Every comparing driver supplies the keyed form. + expect(mountKeyed).toBeDefined() + const keyedPicks: ReadonlyArray = [ + `A`, + `B`, + `C`, + `D`, + `AK`, + `BK`, + ] + for (const first of [`A`, `C`, `AK`] as const) { + for (const second of keyedPicks) { + for (const third of keyedPicks) { + keyedReach.beginHistory() + const history: Array = [first] + const h = mountKeyed!( + (q, pick) => + pick === `D` + ? null + : q + .from({ items: objectOfKeyed(pick).collection }) + .select(({ items }: any) => ({ id: items.id })), + (pick) => (isKeyed(pick) ? [`keyed`, pick] : undefined), + first, + ) + await h.flush() + expectUnorderedRows(h.current().data, rowsOfKeyed(first)) + for (const next of [second, third]) { + const sameIdPriors = history.filter( + (prior) => + prior !== `D` && + next !== `D` && + objectOfKeyed(prior) !== objectOfKeyed(next) && + objectOfKeyed(prior).collection.id === + objectOfKeyed(next).collection.id, + ) + // A claim crosses a keyed step when a keyed step lies + // between the claiming step and the step it binds. + let claimIndex = -1 + for (let index = history.length - 1; index >= 0; index--) { + const prior = history[index]! + if ( + prior !== `D` && + !isKeyed(prior) && + next !== `D` && + !isKeyed(next) && + objectOfKeyed(prior) !== objectOfKeyed(next) && + objectOfKeyed(prior).collection.id === + objectOfKeyed(next).collection.id + ) { + claimIndex = index + break + } + } + if ( + claimIndex >= 0 && + history.slice(claimIndex + 1).some(isKeyed) + ) { + keyedReach.reach(`claim crosses a keyed step`) + } + if ( + isKeyed(next) && + sameIdPriors.some((prior) => !isKeyed(prior)) + ) { + keyedReach.reach(`keyed step after a claim`) + } + if (keyedCollides(history, next)) { + await expectRejection( + `SourceCollectionIdReuseError`, + async () => { + await h.setParam(next) + return h.current() + }, + ) + expectNoRowsAfterRejection(h) + break + } + await h.setParam(next) + history.push(next) + if (next === `D`) { + expectDisabledResult( + h.current(), + driver.disabledRepresentation, + ) + } else { + expectUnorderedRows(h.current().data, rowsOfKeyed(next)) + } + } + h.unmount() + } + } + } + keyedReach.assertReached() + } const picks: ReadonlyArray = [`A`, `B`, `C`, `D`] for (const first of [`A`, `C`] as const) { for (const second of picks) { @@ -1384,6 +1514,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { } reach.assertReached() + if (comparesIdentity) await runKeyedClaimGrammar() + // A new binding starts with no claims. const fresh = driver.mount((q) => q @@ -1394,8 +1526,9 @@ export function runSuite(rawDriver: LiveQueryDriver) { expectUnorderedRows(fresh.current().data, [{ id: `8` }]) fresh.unmount() }, - // 32 histories of three steps each. - 30_000, + // 32 histories of three steps each, and 108 keyed histories for a + // comparing binding. + 60_000, ) scenario( diff --git a/packages/preact-db/tests/conformance.test.ts b/packages/preact-db/tests/conformance.test.ts index 1641aec74b..b62e7d450a 100644 --- a/packages/preact-db/tests/conformance.test.ts +++ b/packages/preact-db/tests/conformance.test.ts @@ -212,6 +212,31 @@ function mountControllable

( } } +// The config-object form with a queryKey chosen per step; an undefined key +// leaves the hook to derive the identity. +function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, +): ControllableHandle

{ + const hook = renderHook( + ({ param }: { param: P }) => + useLiveQuery({ + query: (q: any) => build(q, param), + queryKey: key(param), + } as any), + { initialProps: { param: initial } }, + ) + const handle = makeHandle(hook) + return { + ...handle, + async setParam(param: P) { + hook.rerender({ param }) + await settle() + }, + } +} + const preactDriver: LiveQueryDriver = { name: `preact`, // Each render re-runs the query input and compares its identity. @@ -229,6 +254,7 @@ const preactDriver: LiveQueryDriver = { makeErrorSource, mount, mountControllable, + mountKeyedControllable, mountCollection, mountConfig, mountDisabled, diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 85e8b0e816..52a84fe25e 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -239,6 +239,41 @@ function makeHandle(hook: RenderHookResult) { } } +// The config-object form with a queryKey chosen per step; an undefined key +// leaves the hook to derive the identity, or to follow the dependency list. +const mountKeyedControllableWith = (path: IdentityPath) => + function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + ): ControllableHandle

{ + const hook = renderHook( + ({ param }: { param: P }) => { + const config = { + query: (q: any) => build(q, param), + queryKey: key(param), + } as any + return path === `derived` + ? useLiveQuery(config) + : useLiveQuery(config, [param]) + }, + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook) + return { + ...handle, + async setParam(param: P) { + await guard(() => + act(async () => { + hook.rerender({ param }) + }), + ) + await handle.flush() + }, + } + } + const reactDriver: LiveQueryDriver = { name: `react`, // Each render re-runs the query input and compares its identity. @@ -256,6 +291,7 @@ const reactDriver: LiveQueryDriver = { makeErrorSource, mount, mountControllable: mountControllableWith(`derived`), + mountKeyedControllable: mountKeyedControllableWith(`derived`), mountCollection, mountConfig, mountDisabled, @@ -270,6 +306,7 @@ runSuite({ mount: mountWithDependencyList, mountConfig: mountConfigWithDependencyList, mountControllable: mountControllableWith(`dependency list`), + mountKeyedControllable: mountKeyedControllableWith(`dependency list`), }) it(`publishes a synchronous ordered limit in the first layout commit`, async () => { const source = makeSource([ From 2a54b96625c9786377dc9488f138bc7b6fa3472f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 15:10:37 -0600 Subject: [PATCH 15/17] test: log what each live-query binding publishes across a same-ID swap The reactive branch of the claim grammar checked rows only after a swap settled, so a binding that kept the old source's rows on screen for a turn would pass. Each controllable driver now records a publication log: the value left published at the end of each synchronous turn, plus the value on screen at the first paint after each setParam. After a same-ID swap, every logged value of a reactive binding must hold only the new source's rows. Evidence: - Law: after a source swap under the same Collection ID, a reactive binding publishes only the new source's rows (contract.ts; maintainer decision 2026-10-23). - Reach: every reactive driver crosses a same-ID swap in the claim grammar and records at its framework's publication points. - Cut: a value replaced within one synchronous turn is never painted, so it is not logged. A probe showed Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change; the first per-publication recorder flagged that as a violation. - RED: not applicable; production passes at the first-paint cut. - GREEN: vue-db 140, svelte-db 171, solid-db 103, angular-db 77, react-db 585, preact-db 98. - Mutant: a binding that keeps the old rows published until a later task fails in Vue and in Svelte; the suite before this change passed it. - Limits: React and Preact reject a same-ID swap, so their logs are not read by this law; the microtask boundary is stricter than the next paint. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/angular-db/tests/conformance.test.ts | 21 +++++++++--- packages/db/tests/conformance/contract.ts | 13 +++++++- .../db/tests/conformance/publication-log.ts | 31 +++++++++++++++++ packages/db/tests/conformance/suite.ts | 24 ++++++++++++++ packages/preact-db/tests/conformance.test.ts | 15 +++++++-- packages/react-db/tests/conformance.test.tsx | 33 ++++++++++++++----- packages/solid-db/tests/conformance.test.tsx | 22 ++++++++++--- .../tests/conformance.svelte.test.ts | 11 +++++++ packages/vue-db/tests/conformance.test.ts | 25 +++++++++++--- 10 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 packages/db/tests/conformance/publication-log.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index c03dd1b4e7..8c1af382d8 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Open, owner the maintainer: (3) The reactive branch checks rows after each swap settles, not every publication; a binding that first republishes A's rows and then corrects within one flush would pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index ac0a5521fe..1475e9580e 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -15,6 +15,7 @@ import { DestroyRef, EnvironmentInjector, createEnvironmentInjector, + effect, inject, input, runInInjectionContext, @@ -37,6 +38,7 @@ import { } from '../../db/tests/utils' import { injectLiveQuery } from '../src/index' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' @@ -214,17 +216,28 @@ function mountControllable

( initial: P, ): ControllableHandle

{ const param = signal

(initial) - const { result, destroy } = inCtx(() => - injectLiveQuery({ + const published = createPublicationLog() + const { result, destroy } = inCtx(() => { + const query = injectLiveQuery({ params: () => ({ value: param() }), query: ({ params, q }: any) => build(q, params.value), - }), - ) + }) + // Angular renders the value each effect run reads. + effect(() => { + published.record(query.data()) + }) + return query + }) const handle = makeHandle(result, destroy) return { ...handle, + publications: published.values, async setParam(next: P) { param.set(next) + // The first paint after the change: the framework has flushed its + // reactive work, but no later task has run. + TestBed.tick() + published.record(result.data()) await settle() }, } diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 2acdcba723..976ebf5a6d 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -41,7 +41,8 @@ * Solid, Angular, and Svelte without a `DbClient`) compares no identity. It * compiles any query input, including one without a stable IR identity, and * its rows follow the current input: after a source swap under the same - * Collection ID, it publishes the new source's rows and never the old ones, + * Collection ID, every value it publishes holds only the new source's rows + * (or none while it loads) and never the old ones, * and one query over two same-ID objects reads each of them. * A `DbClient` matches queries by identity hash, so a binding backed by one * follows the comparing law; that boundary has its own receiving witnesses. @@ -183,6 +184,16 @@ export interface LiveQueryHandle { export interface ControllableHandle

extends LiveQueryHandle { /** Rejects when the adapter rejects the reconfigured query input. */ setParam: (param: P) => Promise + /** + * The `data` value the binding left published at the end of each + * synchronous turn since mount, in order. Drivers record at their + * framework's publication points (React and Preact commits, Vue pre-flushes, + * Svelte pre-effects, Solid render effects, Angular effects) through + * `publication-log.ts`, which keeps the last value of each turn: a value + * replaced within one turn is never painted. A law about what a swap may + * show reads these, not only the value after `setParam` settles. + */ + publications: () => ReadonlyArray } /** diff --git a/packages/db/tests/conformance/publication-log.ts b/packages/db/tests/conformance/publication-log.ts new file mode 100644 index 0000000000..94fb31b9d2 --- /dev/null +++ b/packages/db/tests/conformance/publication-log.ts @@ -0,0 +1,31 @@ +/** + * Records what a framework binding leaves published at the end of each + * synchronous turn. A binding may publish several values while one flush + * runs, such as a Solid render effect or a Svelte pre-effect that reads the + * previous query's rows before the binding's own effect replaces them. A + * browser paints only between tasks, so a value replaced within one turn is + * never seen. The microtask boundary is a stricter cut than the next paint: + * a value still published when the turn ends counts, even if a later task + * replaces it before the browser paints. + */ +export function createPublicationLog() { + const turns: Array = [] + let turnOpen = false + return { + /** Record a published value, replacing an earlier one from this turn. */ + record(value: unknown): void { + const copy = Array.isArray(value) ? [...value] : value + if (turnOpen) { + turns[turns.length - 1] = copy + return + } + turns.push(copy) + turnOpen = true + queueMicrotask(() => { + turnOpen = false + }) + }, + /** The value published at the end of each turn, in order. */ + values: (): ReadonlyArray => turns, + } +} diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 50f24ad301..5ebb7fcc75 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -128,6 +128,21 @@ function expectNoRowsAfterRejection(h: LiveQueryHandle): void { } } +/** A published `data` value holds no row outside `rows`. */ +function expectOnlyRowsOf( + value: unknown, + rows: ReadonlyArray<{ id: string }>, +): void { + const allowed = new Set(rows.map(({ id }) => id)) + const published = (Array.isArray(value) ? value : value ? [value] : []).map( + (row: { id: string }) => row.id, + ) + expect( + published.filter((id) => !allowed.has(id)), + `published rows ${JSON.stringify(published)}`, + ).toEqual([]) +} + async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)) } @@ -1497,7 +1512,16 @@ export function runSuite(rawDriver: LiveQueryDriver) { expectNoRowsAfterRejection(h) break } + const published = h.publications().length await h.setParam(next) + if (collides(history, next)) { + // A reactive binding read the swap as a new source: every + // value it published since then holds only the new + // source's rows, or none while it loads. + for (const value of h.publications().slice(published)) { + expectOnlyRowsOf(value, rowsOf[next as Exclude]) + } + } history.push(next) if (next === `D`) { expectDisabledResult( diff --git a/packages/preact-db/tests/conformance.test.ts b/packages/preact-db/tests/conformance.test.ts index b62e7d450a..a1c54161d4 100644 --- a/packages/preact-db/tests/conformance.test.ts +++ b/packages/preact-db/tests/conformance.test.ts @@ -33,6 +33,7 @@ import { } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { renderHook, settle } from './render-probe' @@ -198,13 +199,18 @@ function mountControllable

( build: (q: any, param: P) => any, initial: P, ): ControllableHandle

{ + const published = createPublicationLog() const hook = renderHook( ({ param }: { param: P }) => useLiveQuery((q: any) => build(q, param)), - { initialProps: { param: initial } }, + { + initialProps: { param: initial }, + onCommit: (result) => published.record(result.data), + }, ) const handle = makeHandle(hook) return { ...handle, + publications: published.values, async setParam(param: P) { hook.rerender({ param }) await settle() @@ -219,17 +225,22 @@ function mountKeyedControllable

( key: (param: P) => ReadonlyArray | undefined, initial: P, ): ControllableHandle

{ + const published = createPublicationLog() const hook = renderHook( ({ param }: { param: P }) => useLiveQuery({ query: (q: any) => build(q, param), queryKey: key(param), } as any), - { initialProps: { param: initial } }, + { + initialProps: { param: initial }, + onCommit: (result) => published.record(result.data), + }, ) const handle = makeHandle(hook) return { ...handle, + publications: published.values, async setParam(param: P) { hook.rerender({ param }) await settle() diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 52a84fe25e..7dc0e7bff5 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -26,6 +26,7 @@ import { } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { rejectReadsAfterRenderError } from './rejected-binding' @@ -180,18 +181,27 @@ const mountControllableWith = (path: IdentityPath) => build: (q: any, param: P) => any, initial: P, ): ControllableHandle

{ + const published = createPublicationLog() const hook = renderHook( - ({ param }: { param: P }) => - path === `derived` - ? // The callback captures param, so its IR identity changes with it. - useLiveQuery((q: any) => build(q, param)) - : useLiveQuery((q: any) => build(q, param), [param]), + ({ param }: { param: P }) => { + const result = + path === `derived` + ? // The callback captures param, so its IR identity changes with it. + useLiveQuery((q: any) => build(q, param)) + : useLiveQuery((q: any) => build(q, param), [param]) + // A layout effect records each committed value. + useLayoutEffect(() => { + published.record(result.data) + }) + return result + }, { initialProps: { param: initial } }, ) const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook) return { ...handle, + publications: published.values, async setParam(param: P) { await guard(() => act(async () => { @@ -247,15 +257,21 @@ const mountKeyedControllableWith = (path: IdentityPath) => key: (param: P) => ReadonlyArray | undefined, initial: P, ): ControllableHandle

{ + const published = createPublicationLog() const hook = renderHook( ({ param }: { param: P }) => { const config = { query: (q: any) => build(q, param), queryKey: key(param), } as any - return path === `derived` - ? useLiveQuery(config) - : useLiveQuery(config, [param]) + const result = + path === `derived` + ? useLiveQuery(config) + : useLiveQuery(config, [param]) + useLayoutEffect(() => { + published.record(result.data) + }) + return result }, { initialProps: { param: initial } }, ) @@ -263,6 +279,7 @@ const mountKeyedControllableWith = (path: IdentityPath) => const handle = makeHandle(hook) return { ...handle, + publications: published.values, async setParam(param: P) { await guard(() => act(async () => { diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 777d6ab6ad..4bc6c570b9 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -20,13 +20,19 @@ import { gt, sum, } from '@tanstack/db' -import { createRoot, createSignal, onCleanup } from 'solid-js' +import { + createRenderEffect, + createRoot, + createSignal, + onCleanup, +} from 'solid-js' import { mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' @@ -210,15 +216,23 @@ function mountControllable

( initial: P, ): ControllableHandle

{ const [param, setParam] = createSignal

(initial) - const { getResult, dispose } = inRoot(() => + const published = createPublicationLog() + const { getResult, dispose } = inRoot(() => { // Reading param() inside the query fn makes Solid recompute on change. - useLiveQuery((q: any) => build(q, param())), - ) + const query = useLiveQuery((q: any) => build(q, param())) + // Solid renders the value each render effect reads. + createRenderEffect(() => published.record(query.data)) + return query + }) const handle = makeHandle(getResult, dispose) return { ...handle, + publications: published.values, async setParam(next: P) { setParam(() => next) + // The first paint after the change: the framework ran its reactive + // work synchronously, but no later task has run. + published.record(getResult().data) await settle() }, } diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index ffbb8c1df0..58ae6b9db6 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -26,6 +26,7 @@ import { } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery.svelte.js' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import type { @@ -202,14 +203,24 @@ function mountControllable

( ): ControllableHandle

{ let param = $state(initial) let query: any + const published = createPublicationLog() const dispose = $effect.root(() => { query = useLiveQuery((q: any) => build(q, param), [() => param]) + // Svelte renders the value each pre-effect run reads. + $effect.pre(() => { + published.record(query.data) + }) }) const handle = makeHandle(() => query, dispose) return { ...handle, + publications: published.values, async setParam(next: P) { param = next + // The first paint after the change: the framework has flushed its + // reactive work, but no later task has run. + flushSync() + published.record(query.data) await settle() }, } diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 8988340281..4bf9ec4c5e 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -19,13 +19,14 @@ import { gt, sum, } from '@tanstack/db' -import { effectScope, nextTick, onScopeDispose, ref } from 'vue' +import { effectScope, nextTick, onScopeDispose, ref, watch } from 'vue' import { mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' @@ -209,14 +210,30 @@ function mountControllable

( initial: P, ): ControllableHandle

{ const param = ref(initial) as { value: P } - const { result, scope } = runInScope(() => - useLiveQuery((q: any) => build(q, param.value), [() => param.value]), - ) + const published = createPublicationLog() + const { result, scope } = runInScope(() => { + const query = useLiveQuery( + (q: any) => build(q, param.value), + [() => param.value], + ) + // Vue renders the value present at each pre-flush. + watch( + () => query.data.value, + (data) => published.record(data), + { flush: `pre`, immediate: true }, + ) + return query + }) const handle = makeHandle(result, scope) return { ...handle, + publications: published.values, async setParam(next: P) { param.value = next + // The first paint after the change: the framework has flushed its + // reactive work, but no later task has run. + await nextTick() + published.record(result.data.value) await settle() }, } From f3e0baf607bb0e6a5c4d65770fb6730941c3774f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 16:12:34 -0600 Subject: [PATCH 16/17] fix: close the fourth high review's findings - Release oracles: a rejection cause checks its release at once again; only a render that never commits waits for the reclaim (U4). Those reclaim causes no longer race the real GC floor before the reader (U5). React readers ask for the source synchronously, so a release delayed to a later task cannot hide behind act. - The live publication law requires a logged value after each same-ID swap (U6). - The infinite claim grammar checks the first paint and every published value after a same-ID swap (U2), and generates keyed steps for React and Preact (U3). - releaseDeferredOnReclaim returns a cancel that each hook calls when its render commits, and registers only for a live query the render built (U7). rejectLiveQueryInput's doc names the reclaim release (U8). - Coverage map: a commit held past the 50 ms GC floor releases before it commits; recorded as a known limit (U1). U9 rejected: an overload implementation signature needs `any` (TS2394 with Context). Evidence: - Law: a rejected query releases at its render boundary; a reactive binding publishes only the new source's rows after a same-ID swap; a keyed step records no claim and checks none (contract.ts, maintainer decisions 2026-10-23). - Reach: the infinite keyed grammar reaches "claim crosses a keyed step" and "keyed step after a claim" (75 histories). - RED: not applicable to the oracle repairs; production passes. Each is calibrated by a mutant. - GREEN: react-db 585, preact-db 98, svelte-db 171, vue-db 140, solid-db 103, angular-db 77, db 13790. - Mutant: a release delayed to a later task fails 53 React and 20 Svelte cells (it passed with waitFor); a silenced Vue recorder fails the non-empty log check; a Vue infinite hook that keeps old pages for a turn fails the first-paint check (it passed the earlier suite); a keyed infinite step that claims, or resets claims, fails React and Preact. - Limits: U1 held commits past the GC floor have no witness. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/live-query-options.ts | 13 +- .../db/tests/conformance/infinite-contract.ts | 11 ++ .../conformance/infinite-suite-oracle.ts | 124 +++++++++++++++++- packages/db/tests/conformance/suite.ts | 6 +- .../tests/infinite-query-conformance.test.ts | 22 ++++ packages/react-db/src/useLiveInfiniteQuery.ts | 17 ++- packages/react-db/src/useLiveQuery.ts | 25 ++-- .../tests/infinite-query-conformance.test.tsx | 33 +++++ .../tests/source-id-reuse-oracle.test.tsx | 31 +++-- packages/svelte-db/src/useLiveQuery.svelte.ts | 16 ++- ...riptor-query-release-oracle.svelte.test.ts | 23 ++-- 12 files changed, 278 insertions(+), 45 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 8c1af382d8..d716182626 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 522f4b9298..45bddf1df0 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -38,7 +38,8 @@ export type DeferredLiveQueryCollections = Set< * query the hook rejects leaves no source deferred and a later reader can * start it. A render that throws after the hook returns, such as in a sibling * hook, is not a rejection of this input: like any render that never commits, - * its deferral stays until a `preload()` starts the source. As for a + * it releases its deferral through `releaseDeferredOnReclaim` when garbage + * collection reclaims its live query. As for a * `DbClient` preload, the rejection wins over a source whose resumed start * fails; that source records its own failure. */ @@ -61,15 +62,17 @@ export function rejectLiveQueryInput( * its live query unsubscribed until garbage collection cleans it up; that * cleanup releases the starts, so a direct reader's demand, made before or * after, then starts the sources. Releases only the starts deferred so far, - * not ones a later render of the same hook defers. + * not ones a later render of the same hook defers. Pass only a live query the + * render built, and call the returned function when the render commits, so a + * committed hook keeps no listener or copied sources. */ export function releaseDeferredOnReclaim( liveQuery: Pick, `once`>, deferredCollections: DeferredLiveQueryCollections, -): void { - if (deferredCollections.size === 0) return +): (() => void) | undefined { + if (deferredCollections.size === 0) return undefined const pending: DeferredLiveQueryCollections = new Set(deferredCollections) - liveQuery.once(`status:cleaned-up`, () => { + return liveQuery.once(`status:cleaned-up`, () => { try { resumeDeferredLiveQueryCollections(pending) } catch { diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index a32556f6e6..01c0d4d78e 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -152,6 +152,17 @@ export interface InfiniteQueryDriver { initial: P, config?: InfiniteQueryConfig, ) => InfiniteQueryControllableHandle

+ /** + * Mount a controllable query whose config names `key(param)` as its + * `queryKey`, or none when that returns `undefined`. Every driver that + * compares query identity supplies it; a reactive binding has no keys. + */ + mountKeyedControllable?:

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + config?: InfiniteQueryConfig, + ) => InfiniteQueryControllableHandle

mountCollection: ( collection: Collection, config?: InfiniteQueryConfig, diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index cee5b58e9e..260aa85724 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -1832,8 +1832,35 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { expectNoPagesAfterRejection(handle) break } + const observed = handle.observations().length handle.setParamSync(next) - await handle.flush() + if (collides(history, next)) { + // A reactive binding read the swap as a new source. At the + // first paint, after the framework's microtask flush and + // before any later task, it shows only the new source's + // rows or none; so does every value it publishes after. + await Promise.resolve() + await Promise.resolve() + const allowed = new Set(pagesOf[next].map((row) => row.id)) + const firstPaint = handle + .current() + .data.map((row: { id: string }) => row.id) + expect( + firstPaint.filter((id: string) => !allowed.has(id)), + `first paint ${JSON.stringify(firstPaint)}`, + ).toEqual([]) + await handle.flush() + for (const observation of handle + .observations() + .slice(observed)) { + expect( + observation.ids.filter((id) => !allowed.has(id)), + `published ${JSON.stringify(observation.ids)}`, + ).toEqual([]) + } + } else { + await handle.flush() + } history.push(next) expectPageRows(handle.current(), pagesOf[next], 3) } @@ -1842,9 +1869,100 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { } } reach.assertReached() + + if (!driver.comparesQueryIdentity) return + // Keyed dimension (comparing bindings only), as in the live suite: + // AK reads A(x) and BK reads B(x) under their own keys. A keyed step + // records no claim and checks none, so a claim made before it still + // binds a later derived step. + type KeyedPick = Pick | `AK` | `BK` + const base = (pick: KeyedPick): Pick => + pick === `AK` ? `A` : pick === `BK` ? `B` : pick + const isKeyed = (pick: KeyedPick) => pick !== base(pick) + const conflicts = (prior: KeyedPick, next: KeyedPick) => + !isKeyed(prior) && + !isKeyed(next) && + base(prior) !== base(next) && + sourceOf[base(prior)].collection.id === + sourceOf[base(next)].collection.id + type KeyedReachState = + `claim crosses a keyed step` | `keyed step after a claim` + const keyedReach = createOracleReach( + `infinite source-id claim memory with keys`, + [`claim crosses a keyed step`, `keyed step after a claim`], + ) + const mountKeyed = driver.mountKeyedControllable + // Every comparing driver supplies the keyed form. + expect(mountKeyed).toBeDefined() + const keyedPicks: ReadonlyArray = [`A`, `B`, `C`, `AK`, `BK`] + for (const start of [`A`, `C`, `AK`] as const) { + for (const second of keyedPicks) { + for (const third of keyedPicks) { + keyedReach.beginHistory() + const history: Array = [start] + const handle = mountKeyed!( + (q, pick: KeyedPick) => + q + .from({ items: sourceOf[base(pick)].collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + (pick) => (isKeyed(pick) ? [`keyed`, pick] : undefined), + start as KeyedPick, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), pagesOf[base(start)], 3) + for (const next of [second, third]) { + let claimIndex = -1 + for (let index = history.length - 1; index >= 0; index--) { + if (conflicts(history[index]!, next)) { + claimIndex = index + break + } + } + if ( + claimIndex >= 0 && + history.slice(claimIndex + 1).some(isKeyed) + ) { + keyedReach.reach(`claim crosses a keyed step`) + } + if ( + isKeyed(next) && + history.some( + (prior) => + !isKeyed(prior) && + base(prior) !== base(next) && + sourceOf[base(prior)].collection.id === + sourceOf[base(next)].collection.id, + ) + ) { + keyedReach.reach(`keyed step after a claim`) + } + if (claimIndex >= 0) { + const error = await captureRejection(async () => { + handle.setParamSync(next) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ + name: `SourceCollectionIdReuseError`, + }) + expectNoPagesAfterRejection(handle) + break + } + handle.setParamSync(next) + await handle.flush() + history.push(next) + expectPageRows(handle.current(), pagesOf[base(next)], 3) + } + handle.unmount() + } + } + } + keyedReach.assertReached() }, - // 18 histories of three steps each. - 30_000, + // 18 histories of three steps each, and 75 keyed histories for a + // comparing binding. + 60_000, ) scenario( diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 5ebb7fcc75..8648a67732 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1518,7 +1518,11 @@ export function runSuite(rawDriver: LiveQueryDriver) { // A reactive binding read the swap as a new source: every // value it published since then holds only the new // source's rows, or none while it loads. - for (const value of h.publications().slice(published)) { + const swapped = h.publications().slice(published) + // The driver logs the first paint, so the law cannot pass + // on an empty log. + expect(swapped.length).toBeGreaterThan(0) + for (const value of swapped) { expectOnlyRowsOf(value, rowsOf[next as Exclude]) } } diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts index 55dcea7242..a0c90e6e92 100644 --- a/packages/preact-db/tests/infinite-query-conformance.test.ts +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -167,6 +167,27 @@ function mountControllable

( } } +// The keyed form: the config names a queryKey chosen per step, or none. +function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + config: InfiniteQueryConfig = {}, +) { + const { hook, log } = mountRecorded( + ({ param }: { param: P }) => + useLiveInfiniteQuery((q: any) => build(q, param), { + ...config, + queryKey: key(param), + } as any), + { param: initial }, + ) + return { + ...makeHandle(hook, log), + setParamSync: (param: P) => rerenderSync(hook, { param }), + } +} + function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { const { hook, log } = mountRecorded(() => useLiveInfiniteQuery(collection, config as any), @@ -246,6 +267,7 @@ const preactInfiniteDriver: InfiniteQueryDriver = { makeWindowController: createLiveQueryWindowController, mount, mountControllable, + mountKeyedControllable, mountCollection, mountCollectionControllable, mountConfigControllable, diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index f98db21749..35683d999c 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -108,6 +108,8 @@ type InfiniteQueryRender = { deferredCollections: Set< CollectionImplType > + /** Drops the reclaim release when this render commits. */ + cancelReclaimRelease: (() => void) | undefined warned: boolean } @@ -261,10 +263,20 @@ export function useLiveInfiniteQuery( deferredCollections.add(deferred) } } + const reusesRendered = + newCollection && binding.collection === rendered?.binding.collection currentRenderState = { binding, derivedQuery, deferredCollections, + // A render that never commits releases its deferred sync starts when + // garbage collection reclaims the window collection it built. A reused + // collection keeps the release its first render registered. + cancelReclaimRelease: reusesRendered + ? rendered.cancelReclaimRelease + : newCollection && binding.ownsCollection + ? releaseDeferredOnReclaim(binding.collection, deferredCollections) + : undefined, warned: false, } // Keep a created collection for the next render only once it validated @@ -272,9 +284,6 @@ export function useLiveInfiniteQuery( if (binding.supplied) renderedRef.current = null else if (newCollection && binding.ownsCollection) { renderedRef.current = currentRenderState - // A render that never commits releases its deferred sync starts when - // garbage collection reclaims the window collection it built. - releaseDeferredOnReclaim(binding.collection, deferredCollections) } } const renderState = currentRenderState! @@ -288,6 +297,8 @@ export function useLiveInfiniteQuery( renderState.warned = true console.warn(renderState.binding.warning) } + // A commit releases the starts itself, so it drops the reclaim release. + renderState.cancelReclaimRelease?.() resumeDeferredLiveQueryCollections(renderState.deferredCollections) return unsubscribe }, diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index cb24b3c527..d1dc7697f3 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -724,6 +724,7 @@ function createHookInstance(dbClient: DbClient | undefined) { CollectionImpl >(), sourceIds: createLiveQuerySourceIdClaims(), + cancelReclaimRelease: undefined as (() => void) | undefined, observer: null as LiveQueryObserver | null, queryHash: undefined as string | undefined, suspenseKey: undefined as string | undefined, @@ -747,8 +748,12 @@ function useLiveQueryImpl( const instanceRef = useRef | null>(null) const instance = (instanceRef.current ??= createHookInstance(dbClient)) - const resumeDeferredCollections = () => + // A commit releases the starts itself, so it drops the reclaim listener. + const resumeDeferredCollections = () => { + instance.cancelReclaimRelease?.() + instance.cancelReclaimRelease = undefined resumeDeferredLiveQueryCollections(instance.deferredCollections) + } // The render boundary: a render that throws anywhere after preparation // never commits, so it releases the sync starts it deferred here. @@ -941,6 +946,16 @@ function useLiveQueryImpl( // Without a DbClient, Suspense starts it through a preload below. startSync: !forSuspense || dbClient !== undefined, }) as SuspenseCollection | null + // A render that never commits releases its deferred sync starts + // when garbage collection reclaims the live query it built. A + // Collection the callback returned is not this render's to reclaim. + if (instance.collection && !isCollection(preparedQueryValue)) { + instance.cancelReclaimRelease?.() + instance.cancelReclaimRelease = releaseDeferredOnReclaim( + instance.collection, + instance.deferredCollections, + ) + } if (suspenseCollections && suspenseKey && instance.collection) { const collection = instance.collection const removeCleanupListener = collection.on( @@ -976,14 +991,6 @@ function useLiveQueryImpl( suspenseCollections.set(suspenseKey, entry) } } - // A render that never commits releases its deferred sync starts when - // garbage collection reclaims the live query it bound. - if (instance.collection) { - releaseDeferredOnReclaim( - instance.collection, - instance.deferredCollections, - ) - } instance.config = configOrQueryOrCollection instance.deps = [...identityDeps] } diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 31f5066312..9f264d67fc 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -270,6 +270,37 @@ function mountInputControllable( } } +// The keyed form: the config names a queryKey chosen per step, or none. +const mountKeyedControllableWith = (path: IdentityPath) => + function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + config: InfiniteQueryConfig = {}, + ) { + const log: Observations = [] + const hook = renderHook( + ({ param }: { param: P }) => { + const keyed = { ...config, queryKey: key(param) } as any + return useRecorded( + path === `derived` + ? useLiveInfiniteQuery((q: any) => build(q, param), keyed) + : useLiveInfiniteQuery((q: any) => build(q, param), keyed, [param]), + log, + ) + }, + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook, log) + return { + ...handle, + setParamSync(param: P) { + guard(() => act(() => hook.rerender({ param }))) + }, + } + } + const reactInfiniteDriver: InfiniteQueryDriver = { name: `react`, // Each render re-runs the query input and compares its identity. @@ -290,6 +321,7 @@ const reactInfiniteDriver: InfiniteQueryDriver = { makeWindowController: createLiveQueryWindowController, mount, mountControllable: mountControllableWith(`derived`), + mountKeyedControllable: mountKeyedControllableWith(`derived`), mountCollection, mountCollectionControllable, mountConfigControllable, @@ -303,6 +335,7 @@ runInfiniteQuerySuite({ name: `react (dependency list)`, mount: mountWithDependencyList, mountControllable: mountControllableWith(`dependency list`), + mountKeyedControllable: mountKeyedControllableWith(`dependency list`), }) it(`publishes query-function pages on the first layout commit after mount and dependency replacement`, async () => { 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 da3d4c65a0..f96d570325 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -334,21 +334,20 @@ const releaseHooks: Record = { }, } -// Each reader asks for the source once and returns a read of its row. A -// render that never commits releases its deferral when its live query is -// reclaimed, so the row may arrive after the reader asked. +// Each reader asks for the source synchronously and returns a read of its +// row. A rejection has released already, so the demand starts the source at +// once. A render that never commits releases its deferral when its live +// query is reclaimed, so the row may arrive after the reader asked. const releaseReaders: Record< string, - (shared: ReturnType) => Promise<() => unknown> + (shared: ReturnType) => () => unknown > = { - 'another hook over the Collection': async (shared) => { + 'another hook over the Collection': (shared) => { const reader = renderHook(() => useLiveQuery(shared)) - await act(async () => {}) return () => reader.result.current.data[0]?.value }, - 'startSyncImmediate()': async (shared) => { + 'startSyncImmediate()': (shared) => { shared.startSyncImmediate() - await act(async () => {}) return () => shared.toArray[0]?.value }, } @@ -434,10 +433,20 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, // Declared reach: the rejected render materialized the shared // descriptor, so it held a deferral the law must release. expect(materialized).toBe(1) - expect(syncStarts).toBe(0) + // A rejection releases at its render boundary, before the reader. + // A render that never commits releases at the reclaim, which the + // real GC timer may reach before or after the reader asks. + if (!cause.afterHook) expect(syncStarts).toBe(0) - const value = await read(client.collection(shared) as any) - await waitFor(() => expect(value()).toBe(`shared`)) + const value = read(client.collection(shared) as any) + if (cause.afterHook) { + await waitFor(() => expect(value()).toBe(`shared`)) + } else { + // Released at the render boundary, so the demand started it. + expect(syncStarts).toBe(1) + await act(async () => {}) + expect(value()).toBe(`shared`) + } expect(syncStarts).toBe(1) } finally { consoleError.mockRestore() diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 021d0b2c78..c79776a51d 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -465,14 +465,22 @@ export function useLiveQuery( } // A setup that never subscribes releases its deferred sync starts when - // garbage collection reclaims the live query it built. - if (collection) releaseDeferredOnReclaim(collection, deferredCollections) + // garbage collection reclaims the live query it built. A Collection + // the input supplied is not this setup's to reclaim. + const cancelReclaimRelease = + collection && !isCollection(preparedValue) + ? releaseDeferredOnReclaim(collection, deferredCollections) + : undefined return { collection, client: dbClient, queryHash, - resumeDeferredCollections: () => - resumeDeferredLiveQueryCollections(deferredCollections), + // The subscribing effect releases the starts, so it drops the + // reclaim release. + resumeDeferredCollections: () => { + cancelReclaimRelease?.() + resumeDeferredLiveQueryCollections(deferredCollections) + }, } } catch (error) { return rejectLiveQueryInput(deferredCollections, error) diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index 7eaef7d9da..2786b2c9a1 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -353,16 +353,23 @@ describe(`Svelte descriptor source release`, () => { // descriptor, so it held a deferral the law must release. expect(materialized).toBe(1) // A rejection releases at once, so an eager source starts now. A - // setup that never subscribes releases at the reclaim, so nothing - // has started yet. - expect(starts).toBe( - policy === `eager` && !cause.releasedOnReclaim ? 1 : 0, - ) + // setup that never subscribes releases at the reclaim, which the + // real GC timer may reach before or after this point. + if (cause.releasedOnReclaim) { + expect(starts).toBeLessThanOrEqual(1) + } else { + expect(starts).toBe(policy === `eager` ? 1 : 0) + } const direct = client.collection(shared) await read(direct) - // A setup that never subscribes releases its deferral when its - // live query is reclaimed, which may follow the reader's demand. - await vi.waitFor(() => expect(direct.status).toBe(`ready`)) + // A rejection has released already, so the reader starts the + // source now; a setup that never subscribes releases at the + // reclaim, which may follow the reader's demand. + if (cause.releasedOnReclaim) { + await vi.waitFor(() => expect(direct.status).toBe(`ready`)) + } else { + expect(direct.status).toBe(`ready`) + } expect(starts).toBe(1) } finally { dispose?.() From 2eaa3aad3f19dcf828f03a554d5b7a886d1a214e Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 16:59:23 -0600 Subject: [PATCH 17/17] fix: release a never-committed render's deferral when GC never reclaims A hook config with gcTime 0 or Infinity disables GC, and startSync false never arms it, so the live query a never-committed render built was never reclaimed and its sources stayed deferred. releaseDeferredOnReclaim now follows the lifecycle's own rule: it waits for the reclaim of a started live query with a finite, positive gcTime, and otherwise releases after the unsubscribed GC floor. The lifecycle exports DEFAULT_GC_TIME_MS for both. Evidence: - Law: a render that never commits releases the sync starts it deferred (maintainer decision 2026-10-23). - Reach: the React grammar adds useLiveQuery with gcTime 0, gcTime Infinity, and startSync false; Svelte adds a gcTime 0 setup. Each history asserts the render materialized the shared descriptor. - RED (f3e0baf60): 12 React cells (never-commit causes on the three config paths) fail: the reader cannot start the source. - GREEN: react-db 621, svelte-db 177; release oracles 126 and 38. - Mutant: waiting only for the reclaim fails those 12 React cells and 4 Svelte cells. - Limits: as before, a commit held past the floor releases early. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/lifecycle.ts | 5 +++- packages/db/src/live-query-options.ts | 29 ++++++++++++++----- .../tests/source-id-reuse-oracle.test.tsx | 21 ++++++++++++++ ...riptor-query-release-oracle.svelte.test.ts | 27 +++++++++++++++-- 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index d716182626..ba76ac7d78 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,7 +641,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 variant is skipped, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23); both release grammars add these causes, removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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. | | 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), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3c3a6730bc..f8e513795a 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -31,6 +31,9 @@ import type { CollectionStateManager } from './state' */ export const UNSUBSCRIBED_GC_FLOOR_MS = 50 +/** A Collection's gcTime when its config names none (5 minutes). */ +export const DEFAULT_GC_TIME_MS = 300000 + export class CollectionLifecycleManager< TOutput extends object = Record, TKey extends string | number = string | number, @@ -303,7 +306,7 @@ export class CollectionLifecycleManager< public startGCTimer(minDelay = 0): void { if (!this.canGarbageCollect()) return - const gcTime = this.config.gcTime ?? 300000 // 5 minutes default + const gcTime = this.config.gcTime ?? DEFAULT_GC_TIME_MS // If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled. // Note: setTimeout with Infinity coerces to 0 via ToInt32, causing immediate GC, diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 45bddf1df0..cbdc2dba58 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -1,6 +1,10 @@ import { BaseQueryBuilder } from './query/builder/index.js' import { collectSourceRefs, requireCollectionSource } from './query/ir.js' import { isCollection } from './live-query-primitives.js' +import { + DEFAULT_GC_TIME_MS, + UNSUBSCRIBED_GC_FLOOR_MS, +} from './collection/lifecycle.js' import { createLiveQueryCollection } from './query/live-query-collection.js' import { createPooledLiveQuery, @@ -61,24 +65,35 @@ export function rejectLiveQueryInput( * A render that never commits, because a sibling threw or it suspended, leaves * its live query unsubscribed until garbage collection cleans it up; that * cleanup releases the starts, so a direct reader's demand, made before or - * after, then starts the sources. Releases only the starts deferred so far, - * not ones a later render of the same hook defers. Pass only a live query the - * render built, and call the returned function when the render commits, so a - * committed hook keeps no listener or copied sources. + * after, then starts the sources. A live query that garbage collection never + * reclaims, because its config disables GC or does not start its sync, + * releases after the unsubscribed GC floor instead. Releases only the starts + * deferred so far, not ones a later render of the same hook defers. Pass only + * a live query the render built, and call the returned function when the + * render commits, so a committed hook keeps no listener, timer, or copied + * sources. */ export function releaseDeferredOnReclaim( - liveQuery: Pick, `once`>, + liveQuery: Pick, `once` | `config` | `status`>, deferredCollections: DeferredLiveQueryCollections, ): (() => void) | undefined { if (deferredCollections.size === 0) return undefined const pending: DeferredLiveQueryCollections = new Set(deferredCollections) - return liveQuery.once(`status:cleaned-up`, () => { + const release = () => { try { resumeDeferredLiveQueryCollections(pending) } catch { // A source whose resumed start fails records its own failure. } - }) + } + // The lifecycle's own rule: GC reclaims only a started collection with a + // finite, positive gcTime. + const gcTime = liveQuery.config.gcTime ?? DEFAULT_GC_TIME_MS + if (gcTime > 0 && Number.isFinite(gcTime) && liveQuery.status !== `idle`) { + return liveQuery.once(`status:cleaned-up`, release) + } + const timer = setTimeout(release, UNSUBSCRIBED_GC_FLOOR_MS) + return () => clearTimeout(timer) } /** Release every deferred source, then report the first startup failure. */ 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 f96d570325..a08c9131b2 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -320,6 +320,27 @@ const releaseHooks: Record = { use: (query, deps) => void useLiveInfiniteQuery(query, { pageSize: 1 }, deps), }, + // A config can turn off the reclaim: gcTime 0 or Infinity disables GC, and + // startSync false never arms it. A render that never commits must still + // release its deferral. + 'useLiveQuery with gcTime 0': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery({ query, gcTime: 0 }), + }, + 'useLiveQuery with gcTime Infinity': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery({ query, gcTime: Infinity }), + }, + 'useLiveQuery with startSync false': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery({ query, startSync: false }), + }, 'useLiveSuspenseQuery with derived identity': { ordered: false, suspense: true, diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index 2786b2c9a1..4eb890d8f3 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -9,10 +9,11 @@ * The model has independent source start counts. In the first history, the * first start throws after the Svelte effect asks for rows; the second count * must still become one and the failed effect must release its subscription. - * The release grammar crosses five causes for a client-backed + * The release grammar crosses six causes for a client-backed * query (a descriptor factory that throws during preparation, a query without * a stable IR identity, a query the live query rejects when it compiles, a - * setup that throws after the hook returns, and a different source object + * setup that throws after the hook returns (with the default gcTime, or with + * gcTime 0, which disables the reclaim), and a different source object * under a Collection ID the mounted hook already read), two source start policies, and three direct * readers (`preload()`, `startSyncImmediate()`, and another hook). After the * rejection, an eager (`startSync: true`) source has started once and a lazy @@ -256,6 +257,28 @@ describe(`Svelte descriptor source release`, () => { }, releasedOnReclaim: true, }), + // gcTime 0 disables GC, so the live query is never reclaimed; the setup + // must still release its deferral. + 'a setup that throws after a hook whose live query keeps no GC': ( + shared, + client, + ) => ({ + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ + client, + query: new Query().from({ shared }), + gcTime: 0, + }) + throw new Error(`setup failed`) + }) + }).toThrow(`setup failed`) + return () => dispose?.() + }, + releasedOnReclaim: true, + }), 'a reused source Collection ID': (shared, client) => ({ run: () => { const first = createCollection({