From 92f19fa1a029b3e6d7aa3ecbadd5d20b58c1a1e0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 2 Jul 2026 16:29:56 +0200 Subject: [PATCH] refactor(db): extract shared live-query adapter helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add isCollection, isSingleResultCollection, and getLiveQueryStatusFlags to @tanstack/db and migrate all five adapters to use them. - isCollection: one structural, multi-realm-safe collection guard replacing the per-adapter duck-typing (React/Vue/Svelte/Angular) and Solid's `instanceof CollectionImpl` (which gives false negatives across dual-package boundaries — the same hazard the conformance suite hit). - isSingleResultCollection: shared findOne cardinality check. - getLiveQueryStatusFlags: status → {isLoading,isReady,isIdle,isError,isCleanedUp}; used by React's snapshot path (the reactive adapters derive each flag as its own signal/computed, so a shared object-returning helper doesn't fit them — that duplication is reactivity-coupled and belongs to the observer step). No behavior change; guarded by the conformance suite. First slice of the RFC #1623 extraction (step 2). Status derivation, input/disabled classification, and change→state projection remain — they are reactivity-coupled and land with the observer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/extract-adapter-helpers.md | 12 ++++ packages/angular-db/src/index.ts | 28 ++++---- packages/db/src/index.ts | 1 + packages/db/src/live-query-adapter.ts | 68 +++++++++++++++++++ packages/react-db/src/useLiveQuery.ts | 32 +++------ packages/solid-db/src/useLiveQuery.ts | 10 ++- packages/svelte-db/src/useLiveQuery.svelte.ts | 31 +++------ packages/vue-db/src/useLiveQuery.ts | 24 +++---- 8 files changed, 131 insertions(+), 75 deletions(-) create mode 100644 .changeset/extract-adapter-helpers.md create mode 100644 packages/db/src/live-query-adapter.ts diff --git a/.changeset/extract-adapter-helpers.md b/.changeset/extract-adapter-helpers.md new file mode 100644 index 0000000000..5f956aa12a --- /dev/null +++ b/.changeset/extract-adapter-helpers.md @@ -0,0 +1,12 @@ +--- +'@tanstack/db': minor +'@tanstack/react-db': patch +'@tanstack/vue-db': patch +'@tanstack/svelte-db': patch +'@tanstack/solid-db': patch +'@tanstack/angular-db': patch +--- + +Extract shared live-query adapter helpers into `@tanstack/db` + +Adds `isCollection`, `isSingleResultCollection`, and `getLiveQueryStatusFlags` to `@tanstack/db` and migrates all five framework adapters to use them. `isCollection` replaces the per-adapter duck-typing and Solid's `instanceof CollectionImpl` with one structural, multi-realm-safe guard (the `instanceof` form gave false negatives across dual-package boundaries). No behavior change; internal deduplication only. diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 1bce5c6843..d7dc2c1c87 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -6,11 +6,15 @@ import { inject, signal, } from '@angular/core' -import { BaseQueryBuilder, createLiveQueryCollection } from '@tanstack/db' +import { + BaseQueryBuilder, + createLiveQueryCollection, + isCollection, + isSingleResultCollection, +} from '@tanstack/db' import type { ChangeMessage, Collection, - CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, @@ -139,14 +143,7 @@ export function injectLiveQuery(opts: any) { const collection = computed(() => { // Check if it's an existing collection - const isExistingCollection = - opts && - typeof opts === `object` && - typeof opts.subscribeChanges === `function` && - typeof opts.startSyncImmediate === `function` && - typeof opts.id === `string` - - if (isExistingCollection) { + if (isCollection(opts)) { return opts } @@ -214,10 +211,9 @@ export function injectLiveQuery(opts: any) { if (!currentCollection) { return internalData() } - const config = currentCollection.config as - | CollectionConfigSingleRowOption - | undefined - return config?.singleResult ? internalData()[0] : internalData() + return isSingleResultCollection(currentCollection) + ? internalData()[0] + : internalData() }) const syncDataFromCollection = ( @@ -282,7 +278,9 @@ export function injectLiveQuery(opts: any) { return { state, data, - collection, + // Loosely typed so the impl return stays compatible with every overload + // (the shared `isCollection` guard narrows the computed to `Collection | null`). + collection: collection as Signal, status, isLoading: computed(() => status() === `loading`), isReady: computed(() => status() === `ready` || status() === `disabled`), diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 347a3119b5..71e264d712 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -10,6 +10,7 @@ export * from './types' export * from './proxy' export * from './query/index.js' export * from './optimistic-action' +export * from './live-query-adapter' export * from './local-only' export * from './local-storage' export * from './errors' diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts new file mode 100644 index 0000000000..b13001d0a8 --- /dev/null +++ b/packages/db/src/live-query-adapter.ts @@ -0,0 +1,68 @@ +import type { Collection } from './collection/index.js' +import type { CollectionStatus } from './types.js' + +/** + * Shared helpers for the first-party framework adapters (`@tanstack/react-db`, + * `@tanstack/vue-db`, `@tanstack/svelte-db`, `@tanstack/solid-db`, + * `@tanstack/angular-db`). + * + * 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. + */ + +/** + * 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` + ) +} + +/** 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/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 2291c2019a..8dc3d0a31b 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,12 +1,13 @@ import { useRef, useSyncExternalStore } from 'react' import { BaseQueryBuilder, - CollectionImpl, createLiveQueryCollection, + getLiveQueryStatusFlags, + isCollection, + isSingleResultCollection, } from '@tanstack/db' import type { Collection, - CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, @@ -317,13 +318,8 @@ export function useLiveQuery( configOrQueryOrCollection: any, deps: Array = [], ) { - // Check if it's already a collection by checking for specific collection methods - const isCollection = - configOrQueryOrCollection && - typeof configOrQueryOrCollection === `object` && - typeof configOrQueryOrCollection.subscribeChanges === `function` && - typeof configOrQueryOrCollection.startSyncImmediate === `function` && - typeof configOrQueryOrCollection.id === `string` + // Check if it's already a collection + const inputIsCollection = isCollection(configOrQueryOrCollection) // Use refs to cache collection and track dependencies const collectionRef = useRef | null>( @@ -342,14 +338,14 @@ export function useLiveQuery( // Check if we need to create/recreate the collection const needsNewCollection = !collectionRef.current || - (isCollection && configRef.current !== configOrQueryOrCollection) || - (!isCollection && + (inputIsCollection && configRef.current !== configOrQueryOrCollection) || + (!inputIsCollection && (depsRef.current === null || depsRef.current.length !== deps.length || depsRef.current.some((dep, i) => dep !== deps[i]))) if (needsNewCollection) { - if (isCollection) { + 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 @@ -379,7 +375,7 @@ export function useLiveQuery( if (result === undefined || result === null) { // Callback returned undefined/null - disabled query collectionRef.current = null - } else if (result instanceof CollectionImpl) { + } else if (isCollection(result)) { // Callback returned a Collection instance - use it directly result.startSyncImmediate() collectionRef.current = result @@ -527,9 +523,7 @@ export function useLiveQuery( } else { // Capture a stable view of entries for this snapshot to avoid tearing const entries = Array.from(snapshot.collection.entries()) - const config: CollectionConfigSingleRowOption = - snapshot.collection.config - const singleResult = config.singleResult + const singleResult = isSingleResultCollection(snapshot.collection) let stateCache: Map | null = null let dataCache: Array | null = null @@ -548,11 +542,7 @@ export function useLiveQuery( }, collection: snapshot.collection, status: snapshot.collection.status, - isLoading: snapshot.collection.status === `loading`, - isReady: snapshot.collection.status === `ready`, - isIdle: snapshot.collection.status === `idle`, - isError: snapshot.collection.status === `error`, - isCleanedUp: snapshot.collection.status === `cleaned-up`, + ...getLiveQueryStatusFlags(snapshot.collection.status), isEnabled: true, } } diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 5380bae00d..7759915f32 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -9,15 +9,15 @@ import { import { ReactiveMap } from '@solid-primitives/map' import { BaseQueryBuilder, - CollectionImpl, createLiveQueryCollection, + isCollection, + isSingleResultCollection, } from '@tanstack/db' import { createStore, reconcile } from 'solid-js/store' import type { Accessor } from 'solid-js' import type { ChangeMessage, Collection, - CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, @@ -311,7 +311,7 @@ export function useLiveQuery( return null } - if (innerCollection instanceof CollectionImpl) { + if (isCollection(innerCollection)) { innerCollection.startSyncImmediate() return innerCollection as Collection } @@ -426,9 +426,7 @@ export function useLiveQuery( function getData() { const currentCollection = collection() if (currentCollection) { - const config: CollectionConfigSingleRowOption = - currentCollection.config - if (config.singleResult) { + if (isSingleResultCollection(currentCollection)) { // Force resource tracking so Suspense works getDataResource() return data[0] diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 76dedd3c78..789d3d08f8 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -2,11 +2,15 @@ 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, createLiveQueryCollection } from '@tanstack/db' +import { + BaseQueryBuilder, + createLiveQueryCollection, + isCollection, + isSingleResultCollection, +} from '@tanstack/db' import type { ChangeMessage, Collection, - CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, @@ -301,14 +305,9 @@ export function useLiveQuery( } // Check if it's already a collection by checking for specific collection methods - const isCollection = - unwrappedParam && - typeof unwrappedParam === `object` && - typeof unwrappedParam.subscribeChanges === `function` && - typeof unwrappedParam.startSyncImmediate === `function` && - typeof unwrappedParam.id === `string` - - if (isCollection) { + const inputIsCollection = isCollection(unwrappedParam) + + 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 @@ -474,16 +473,8 @@ export function useLiveQuery( }, get data() { const currentCollection = collection - if (currentCollection) { - const config = - currentCollection.config as CollectionConfigSingleRowOption< - any, - any, - any - > - if (config.singleResult) { - return internalData[0] - } + if (currentCollection && isSingleResultCollection(currentCollection)) { + return internalData[0] } return internalData }, diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 479c92b2b0..762cbda0a6 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -8,11 +8,14 @@ import { toValue, watchEffect, } from 'vue' -import { createLiveQueryCollection } from '@tanstack/db' +import { + createLiveQueryCollection, + isCollection, + isSingleResultCollection, +} from '@tanstack/db' import type { ChangeMessage, Collection, - CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, @@ -265,15 +268,10 @@ export function useLiveQuery( } } - // Check if it's already a collection by checking for specific collection methods - const isCollection = - unwrappedParam && - typeof unwrappedParam === `object` && - typeof unwrappedParam.subscribeChanges === `function` && - typeof unwrappedParam.startSyncImmediate === `function` && - typeof unwrappedParam.id === `string` + // Check if it's already a collection + const inputIsCollection = isCollection(unwrappedParam) - if (isCollection) { + 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 @@ -347,9 +345,9 @@ export function useLiveQuery( if (!currentCollection) { return internalData } - const config: CollectionConfigSingleRowOption = - currentCollection.config - return config.singleResult ? internalData[0] : internalData + return isSingleResultCollection(currentCollection) + ? internalData[0] + : internalData }) // Track collection status reactively