@tanstack/db
Version:
A reactive client store for building super fast apps on sync
493 lines (467 loc) • 17.5 kB
text/typescript
import { BaseQueryBuilder } from './query/builder/index.js'
import { collectSourceRefs, requireCollectionSource } from './query/ir.js'
import { isCollection, isFailedStatus } 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 { LIVE_QUERY_INTERNAL } from './query/live/internal.js'
import { getBuilderFromConfig } from './query/live/collection-registry.js'
import {
getQueryIdentity,
getStableQueryBuilderHash,
getStableValueHash,
} from './query/ir-stable-identity.js'
import { getStringCollationIdentity } from './query/runtime-reference-identity.js'
import { codedMessage, devBuild } from './error-message.js'
import { InvalidPoolOptionError, UnhashableQueryIRError } from './errors.js'
import type { Collection, CollectionImpl } from './collection/index.js'
import type { CollectionOptions, DbClient } from './client.js'
import type { PooledLiveQueries } from './query/pooled-live-query.js'
import type {
Context,
InitialQueryBuilder,
LiveQueryCollectionConfig,
LiveQueryHookConfig,
QueryBuilder,
} from './query/index.js'
/**
* A hook config's pool: `pooledLiveQueries` or nothing. Any other value, such
* as the removed boolean form, contradicts the hook contract, so every reader
* rejects it the same way, whatever the config's other options. An adapter
* that reads the pool from its own options, as Angular does, checks it here.
*/
export function configPool(config: object): PooledLiveQueries | undefined {
const { pool } = config as { pool?: unknown }
if (
pool !== undefined &&
typeof (pool as Partial<PooledLiveQueries> | null)
?.createPooledLiveQuery !== `function`
)
throw new InvalidPoolOptionError()
return pool as PooledLiveQueries | undefined
}
export type LiveQueryKey = ReadonlyArray<unknown>
export type LiveQueryOptions = LiveQueryCollectionConfig<any> & {
queryKey?: LiveQueryKey
}
export type DeferredLiveQueryCollections = Set<
CollectionImpl<any, string | number, any, any, any>
>
/**
* Release the sync starts a rejected query input deferred, then rethrow its
* 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,
* 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.
*/
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 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. 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<Collection<any, any, any>, `once` | `config` | `status`>,
deferredCollections: DeferredLiveQueryCollections,
): (() => void) | undefined {
if (deferredCollections.size === 0) return undefined
const pending: DeferredLiveQueryCollections = new Set(deferredCollections)
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. */
export function resumeDeferredLiveQueryCollections(
collections: DeferredLiveQueryCollections,
reportSourceErrors = false,
): void {
const pending = Array.from(collections)
collections.clear()
let firstFailure: { error: unknown } | undefined
for (const collection of pending) {
try {
collection._resumeSyncStart()
// An explicit preload owns source failure reporting even when graph
// demand already released the gate. Ordinary hooks read the status.
if (reportSourceErrors && isFailedStatus(collection.status)) {
throw collection._lifecycle.getSyncError()
}
} catch (error) {
firstFailure ??= { error }
}
}
if (firstFailure) throw firstFailure.error
}
type PreparedLiveQueryConfigInput = Omit<
LiveQueryHookConfig<Context>,
`query`
> & {
query:
| QueryBuilder<Context>
| ((q: InitialQueryBuilder) => QueryBuilder<Context> | undefined | null)
queryKey?: LiveQueryKey
client?: DbClient
}
export function prepareLiveQueryValue(
value: unknown,
dbClient: DbClient | undefined,
deferredCollections?: DeferredLiveQueryCollections,
): unknown {
if (typeof value === `function`) {
return prepareLiveQueryValue(
value(new BaseQueryBuilder() as InitialQueryBuilder),
dbClient,
deferredCollections,
)
}
if (value instanceof BaseQueryBuilder) {
return value._bindCollectionSources(
dbClient
? (options) => {
const config = options as CollectionOptions<
any,
string | number,
any,
any
>
const collection = (
deferredCollections
? dbClient._materializeCollectionForRender(config)
: dbClient.collection(config)
) as CollectionImpl<any, string | number, any, any, any>
if (deferredCollections && collection._deferSyncStart())
deferredCollections.add(collection)
return collection
}
: undefined,
)
}
if (
value &&
typeof value === `object` &&
!isCollection(value) &&
`query` in value
) {
const pool = configPool(value)
const {
query,
queryKey: _queryKey,
client: _client,
pool: _pool,
...config
} = value as PreparedLiveQueryConfigInput
const preparedQuery = prepareLiveQueryValue(
query,
dbClient,
deferredCollections,
)
if (preparedQuery === undefined || preparedQuery === null) {
return preparedQuery
}
return {
...config,
// A DbClient query never pools, so its identity must not depend on
// pool: a server preload and a client hook must hash alike.
...(pool && !dbClient ? { pool } : {}),
query: preparedQuery,
}
}
return value
}
/** Concrete source objects in query-position order for a prepared value. */
export function getPreparedLiveQuerySources(
value: unknown,
onUnboundDescriptor?: (alias: string) => never,
): Array<Collection> {
if (isCollection(value)) return [value]
const query = queryConfig(value)?.query ?? value
if (query instanceof BaseQueryBuilder) {
return collectSourceRefs(query._getQuery()).map((source) =>
source.type === `descriptorRef` && onUnboundDescriptor
? onUnboundDescriptor(source.alias)
: requireCollectionSource(source),
)
}
return isCollection(query) ? [query] : []
}
/** Local row behavior, kept separate from the portable query identity. */
export type LiveQueryRowOptions = readonly [getKey: unknown, schema: unknown]
export function getLiveQueryRowOptions(value: unknown): LiveQueryRowOptions {
if (isCollection(value)) {
const { config } = value
const internal = config.utils?.[LIVE_QUERY_INTERNAL] as
{ hasCustomGetKey?: boolean } | undefined
return [
internal?.hasCustomGetKey ? config.getKey : undefined,
config.schema,
]
}
const config = value as
{ getKey?: unknown; schema?: unknown } | null | undefined
return [config?.getKey, config?.schema]
}
export function getPreparedLiveQueryIdentity(
value: unknown,
pool?: PooledLiveQueries,
): unknown {
if (isCollection(value)) return [`collection`, value.id]
if (value instanceof BaseQueryBuilder) {
// A pooled query's fields and literals identify its rows without
// canonicalizing its whole IR on every render.
const pooled = pool?.getPooledQueryIdentity(value)
return pooled === undefined
? [`query`, getStableQueryBuilderHash(value)]
: [`pooled`, pooled]
}
if (value && typeof value === `object` && `query` in value) {
const config = value as LiveQueryCollectionConfig<any>
return [
// getKey and schema bind for this query identity. Inline options must
// not turn a normal framework render into a new Collection.
`config`,
getPreparedLiveQueryIdentity(config.query, configPool(config)),
[`singleResult`, config.singleResult === true],
[
`defaultStringCollation`,
getStringCollationIdentity(config.defaultStringCollation),
],
]
}
if (value === undefined || value === null) return [`disabled`]
return [`value`, value]
}
export function getLiveQueryHash(
preparedValue: unknown,
queryKey?: LiveQueryKey,
): string {
if (queryKey?.length) {
const query = queryConfig(preparedValue)?.query
const ir = isCollection(preparedValue)
? getBuilderFromConfig(preparedValue.config)?.getWindow()
: query instanceof BaseQueryBuilder
? query._getQuery()
: undefined
const identity: Array<unknown> = [`queryKey`, queryKey]
if (ir && (ir.limit !== undefined || (ir.offset ?? 0) !== 0))
identity.push({ limit: ir.limit, offset: ir.offset ?? 0 })
return getStableValueHash(identity, `queryKey`)
}
const identity = isCollection(preparedValue)
? [`collection`, preparedValue.id]
: [`derived`, getPreparedLiveQueryIdentity(preparedValue)]
return getStableValueHash(identity, `queryKey`)
}
// Each entry belongs to one framework binding or an unclaimed retry resource.
// DbClient snapshots remain separate; peers never share a mutable window owner.
const resolvedQueryValues = new WeakMap<
object,
{ collection: object; config: NonNullable<ReturnType<typeof queryConfig>> }
>()
function queryConfig(
value: unknown,
): (Record<string, unknown> & { query?: unknown }) | undefined {
return value instanceof BaseQueryBuilder
? { query: value }
: value && typeof value === `object`
? (value as Record<string, unknown>)
: undefined
}
function moveExistingWindow(
after: ReturnType<typeof queryConfig>,
previous: Collection<object, string | number, {}> | null | undefined,
owner: object | undefined,
) {
if (
!previous ||
(previous.status !== `ready` && previous.status !== `loading`)
)
return
const retained = owner && resolvedQueryValues.get(owner)
if (!retained || retained.collection !== previous) return
const before = retained.config
if (
!(before.query instanceof BaseQueryBuilder) ||
!(after?.query instanceof BaseQueryBuilder)
)
return
const oldQuery = before.query._getQuery()
const query = after.query._getQuery()
const limit = query.limit
const offset = query.offset ?? 0
// Adding/removing the outer limit, findOne, and opaque plans keep their
// ordinary compilation path. A move needs a finite ordered target window.
if (
after.singleResult === true ||
query.singleResult ||
!query.orderBy?.length ||
oldQuery.limit === undefined ||
limit === undefined ||
!Number.isSafeInteger(limit) ||
limit < 0 ||
!Number.isSafeInteger(offset) ||
offset < 0
)
return
const keys = Object.keys(before)
if (
keys.length !== Object.keys(after).length ||
keys.some((key) => key !== `query` && before[key] !== after[key])
)
return
// IR source IDs establish semantic identity, but a keyed query may legally
// swap the object under an ID. Reuse must preserve the exact source objects.
const sources = getPreparedLiveQuerySources(before)
const nextSources = getPreparedLiveQuerySources(after)
if (
sources.length !== nextSources.length ||
sources.some((source, index) => source !== nextSources[index])
)
return
try {
if (
getQueryIdentity({ ...oldQuery, limit: undefined, offset: undefined }) !==
getQueryIdentity({ ...query, limit: undefined, offset: undefined })
)
return
} catch (error) {
if (error instanceof UnhashableQueryIRError) return
throw error
}
const builder = getBuilderFromConfig(previous.config)
if (!builder) return
return () => {
// A framework can commit synchronously from a Collection publication
// callback. Move after that publication, while this remains the binding's
// last resolved result and a consumer still asks for it.
queueMicrotask(() => {
if (
resolvedQueryValues.get(owner)?.collection !== previous ||
previous.subscriberCount === 0
)
return
const result = builder.setWindow({ offset, limit })
// The Collection reports request failures through its health tree. A
// superseded move or unmount can reject its waiter without a hook caller.
if (result !== true) void result.catch(() => {})
})
}
}
/**
* 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,
{
gcTime,
pool,
startSync = true,
previousCollection,
windowOwner,
onWindowChange,
}: {
gcTime?: number
// `false` keeps a hook that cannot pool, such as a Suspense or DbClient
// query, from pooling even when its config passes `pooledLiveQueries`.
// A pool here has passed `configPool`.
pool?: PooledLiveQueries | false
startSync?: boolean
/** @internal undefined skips window tracking; null starts an owned result. */
previousCollection?: Collection<object, string | number, {}> | null
/** @internal Identity of one framework binding; never shared by peers. */
windowOwner?: object
/** @internal React/Preact defer mutation until their render commits. */
onWindowChange?: (move: () => void) => void
} = {},
): Collection<any, any, any> | null {
if (previousCollection !== undefined)
value = prepareLiveQueryValue(value, undefined)
const input = queryConfig(value)
const move = moveExistingWindow(input, previousCollection, windowOwner)
if (move) {
if (onWindowChange) onWindowChange(move)
else move()
return previousCollection!
}
if (value === undefined || value === null) return null
if (isCollection(value)) {
value.startSyncImmediate()
return value
}
if (input) {
const requested = configPool(input)
const { pool: _pool, ...given } = input as LiveQueryHookConfig<any>
const pooling = pool === false ? undefined : (requested ?? pool)
// A pooled view needs the built query. Build a query function once and
// compile the same builder if the partition cannot serve it.
const built =
pooling && typeof given.query === `function`
? given.query(new BaseQueryBuilder() as InitialQueryBuilder)
: undefined
const config =
built instanceof BaseQueryBuilder ? { ...given, query: built } : given
// A query and lifetime alone can pool; other options shape a Collection.
const pooled =
pooling &&
config.query instanceof BaseQueryBuilder &&
Object.keys(config).every((key) => key === `query` || key === `gcTime`)
? pooling.createPooledLiveQuery(config.query, {
gcTime: config.gcTime ?? gcTime,
})
: undefined
const collection =
pooled ??
createLiveQueryCollection({
...config,
startSync: config.startSync ?? startSync,
gcTime: config.gcTime ?? gcTime,
})
if (previousCollection !== undefined && windowOwner)
resolvedQueryValues.set(windowOwner, { collection, config: input })
return collection
}
throw new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `A live query must be a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof value}`
: codedMessage(102, { type: typeof value }),
)
}