Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/extract-adapter-helpers.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 13 additions & 15 deletions packages/angular-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -214,10 +211,9 @@ export function injectLiveQuery(opts: any) {
if (!currentCollection) {
return internalData()
}
const config = currentCollection.config as
| CollectionConfigSingleRowOption<any, any, any>
| undefined
return config?.singleResult ? internalData()[0] : internalData()
return isSingleResultCollection(currentCollection)
? internalData()[0]
: internalData()
})

const syncDataFromCollection = (
Expand Down Expand Up @@ -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<any>,
status,
isLoading: computed(() => status() === `loading`),
isReady: computed(() => status() === `ready` || status() === `disabled`),
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
68 changes: 68 additions & 0 deletions packages/db/src/live-query-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<any, any, any> {
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<any, any, any>,
): 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`,
}
}
32 changes: 11 additions & 21 deletions packages/react-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -317,13 +318,8 @@ export function useLiveQuery(
configOrQueryOrCollection: any,
deps: Array<unknown> = [],
) {
// 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<Collection<object, string | number, {}> | null>(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<any, any, any> =
snapshot.collection.config
const singleResult = config.singleResult
const singleResult = isSingleResultCollection(snapshot.collection)
let stateCache: Map<string | number, unknown> | null = null
let dataCache: Array<unknown> | null = null

Expand All @@ -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,
}
}
Expand Down
10 changes: 4 additions & 6 deletions packages/solid-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -311,7 +311,7 @@ export function useLiveQuery(
return null
}

if (innerCollection instanceof CollectionImpl) {
if (isCollection(innerCollection)) {
innerCollection.startSyncImmediate()
return innerCollection as Collection
}
Expand Down Expand Up @@ -426,9 +426,7 @@ export function useLiveQuery(
function getData() {
const currentCollection = collection()
if (currentCollection) {
const config: CollectionConfigSingleRowOption<any, any, any> =
currentCollection.config
if (config.singleResult) {
if (isSingleResultCollection(currentCollection)) {
// Force resource tracking so Suspense works
getDataResource()
return data[0]
Expand Down
31 changes: 11 additions & 20 deletions packages/svelte-db/src/useLiveQuery.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
},
Expand Down
24 changes: 11 additions & 13 deletions packages/vue-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -347,9 +345,9 @@ export function useLiveQuery(
if (!currentCollection) {
return internalData
}
const config: CollectionConfigSingleRowOption<any, any, any> =
currentCollection.config
return config.singleResult ? internalData[0] : internalData
return isSingleResultCollection(currentCollection)
? internalData[0]
: internalData
})

// Track collection status reactively
Expand Down
Loading