UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

312 lines (297 loc) • 11.4 kB
import { codedMessage, devBuild } from './error-message.js' import { UnhashableQueryIRError } from './errors.js' import { getLiveQueryHash } from './live-query-options.js' import { isFailedStatus } from './live-query-primitives.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 { DbClient } from './client.js' import type { LiveQueryKey } from './live-query-options.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`. * - `always`: on every update, as for a reactive rerun, which happens only * when something the query reads changed. */ export type LiveQueryWindowRecompile = `identity` | `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<unknown> /** Context compared by reference, such as the `DbClient` a query binds to. */ scope?: unknown /** Hydration identity for a client-backed window. */ client?: DbClient queryKey?: LiveQueryKey recompile: LiveQueryWindowRecompile } export interface LiveQueryWindowBinding extends LiveQueryWindowPageShape { /** The supplied Collection; `undefined` for a query input. */ supplied: Collection<any, any, any> | undefined dependencies: ReadonlyArray<unknown> | undefined scope: unknown collection: Collection<any, any, any> /** The transport identity of this binding's initial query window. */ queryHash: string | undefined /** Whether this binding created `collection` from a query. */ ownsCollection: boolean controller: LiveQueryWindowController<any, any> /** * 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<any>(update.input) : undefined const supplied = resolved === undefined ? getLiveQueryWindowInputKind(update.input) === `collection` ? (update.input as Collection<any, any, any>) : undefined : resolved.kind === `collection` ? resolved.collection : undefined const inputKind = supplied ? `collection` : `query` const previousKind = previous ? previous.supplied ? `collection` : `query` : undefined // 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 = 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 queryHash = previous?.queryHash 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` && !isFailedStatus(uncommitted.collection.status) && 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 or crashed, such as // after its source restarted, cannot serve the query again. collection = uncommitted.collection queryHash = uncommitted.queryHash ownsCollection = true } else { const window = openWindowCollection( resolved ?? resolveLiveQueryWindowInput<any>(update.input), requiredLimit, update, ) collection = window.collection queryHash = window.queryHash ownsCollection = window.ownsCollection warning = window.warning } } return { supplied, dependencies, scope: update.scope, pageSize: update.pageSize, initialPageParam: update.initialPageParam, collection: collection!, queryHash, ownsCollection, controller: createLiveQueryWindowController(collection, { pageSize: update.pageSize, initialPageParam: update.initialPageParam, initialPageCount, client: update.client, queryHash, }), warning, } } /** How an update's query identity differs from a binding's. */ function compareUpdate( binding: LiveQueryWindowBinding | null, update: LiveQueryWindowUpdate, dependencies: ReadonlyArray<unknown> | 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<typeof resolveLiveQueryWindowInput<any>>, requiredLimit: number, update: LiveQueryWindowUpdate, ): { collection: Collection<any, any, any> queryHash: string | undefined ownsCollection: boolean warning: string | undefined } { const prepared = input.kind === `collection` ? input.collection : { query: input.query.limit(requiredLimit).offset(0) } let queryHash: string | undefined if (update.client) { try { queryHash = getLiveQueryHash(prepared, update.queryKey) } catch (error) { if (!(error instanceof UnhashableQueryIRError) || update.queryKey?.length) throw error } } if (input.kind === `collection`) { const warning = getLiveQueryWindowCollectionWarning( input.collection, update.pageSize + 1, ) if (liveQueryWindowMatches(input.collection, requiredLimit)) { input.collection.startSyncImmediate() } return { collection: input.collection, queryHash, ownsCollection: false, warning, } } const collection = createLiveQueryCollection({ query: (prepared as { query: typeof input.query }).query, 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, queryHash, ownsCollection: true, warning: undefined } }