@tanstack/db
Version:
A reactive client store for building super fast apps on sync
312 lines (297 loc) • 11.4 kB
text/typescript
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 }
}