UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

384 lines (358 loc) • 12.7 kB
import { inArray } from '../builder/functions.js' import { PropRef } from '../ir.js' import { createValueIdentity } from '../equality-value-identity.js' import { codedMessage, devBuild } from '../../error-message.js' import { normalizeError } from '../../utils/error.js' import type { ValueIdentity } from '../equality-value-identity.js' import type { CollectionSubscription } from '../../collection/subscription.js' import type { LazyCollectionCallbacks, LazyDemandPlan, } from '../compiler/joins.js' import type { BasicExpression } from '../ir.js' type DemandSegment = { keys: Map<string, unknown> where: BasicExpression<boolean> abortController: AbortController ready: true | Promise<void> state: `starting` | `pending` | `settled` | `failed` replaces?: Array<DemandSegment> } type DemandState = { keys: Map<string, unknown> segments: Array<DemandSegment> } export type DemandUpdate = { changed: boolean empty: boolean ready: Promise<Array<unknown>> | true } /** * Keeps lazy subset requests aligned with the current relation of demanded * keys. Growth loads only new keys. Churn replaces fragmented coverage after * the replacement applies, so prior coverage remains live in the meantime. */ export class SubsetDemandController { private readonly states = new Map<string, DemandState>() /** * `onKeyOutcome`: the latest load of a key the plan needs failed with * `error`; without one, a load of the key succeeded or the plan no longer * needs it. A request error belongs to the key's failed request. */ constructor( private readonly onKeyOutcome?: ( planId: string, key: string, error?: Error, ) => void, ) {} private readonly warnedPlans = new Set<string>() private valueIdentity = createValueIdentity() /** * A truncate's or a reload's replay was judged: its verdict decides every * request error of the demand it replayed. Its success clears them, and * its failure replaces them with the replay's own failures, which the * query reports for the replay. */ clearRequestErrors(): void { for (const [planId, state] of this.states) for (const key of state.keys.keys()) this.onKeyOutcome?.(planId, key) } setDemand( subscription: CollectionSubscription, plan: LazyDemandPlan, keys: Set<unknown>, ): DemandUpdate { const nextKeys = canonicalizeKeys(keys, this.valueIdentity) const previous = this.states.get(plan.id) const hasFailedCoverage = previous?.segments.some( (segment) => segment.state === `failed` && intersects(segment.keys, nextKeys), ) if ( previous && equalKeySets(previous.keys, nextKeys) && !hasFailedCoverage ) { return { changed: false, empty: nextKeys.size === 0, ready: true } } const segments = (previous?.segments ?? []).filter( (segment) => segment.state !== `failed` && intersects(segment.keys, nextKeys) && (!segment.replaces || equalKeySets(segment.keys, nextKeys)), ) const retired = (previous?.segments ?? []).filter( (segment) => !segments.includes(segment), ) const retryReplacement = previous?.segments.some( (segment) => segment.replaces && segment.state === `failed` && equalKeySets(segment.keys, nextKeys), ) const state: DemandState = { keys: nextKeys, segments } this.states.set(plan.id, state) // A key the plan no longer needs shows no request error. for (const key of previous?.keys.keys() ?? []) if (!nextKeys.has(key)) this.onKeyOutcome?.(plan.id, key) // Law 9: a demand change is one step. Start its new demand before it // releases the retired segments, and release them together, so an open // replay keeps the new demand and is judged once, after the whole change. const release = () => releaseSegments(subscription, retired) if (nextKeys.size === 0) { this.states.delete(plan.id) release() return { changed: true, empty: true, ready: true } } const coveredKeys = new Set( segments.flatMap((segment) => [...segment.keys.keys()]), ) const added = new Map( [...nextKeys].filter(([key]) => !coveredKeys.has(key)), ) const removed = previous ? [...previous.keys.keys()].some((key) => !nextKeys.has(key)) : false const replace = retryReplacement || (removed && (added.size > 0 || segments.length > 1)) const requestedKeys = replace ? nextKeys : added // A replacement alone decides the demand: the segments it supersedes are // released, and aborted, once it settles. let replacement: DemandSegment | undefined try { if (requestedKeys.size > 0) { const segment = createSegment(plan, requestedKeys) if (replace) { segment.replaces = [...segments] replacement = segment } segments.push(segment) const started = startSegment( subscription, segment, () => this.warnUnoptimized(plan), (error) => this.recordOutcome(plan.id, segment, error), ) if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } } if (!started) throw new Error( devBuild() && process.env.NODE_ENV !== `production` ? `Subset demand snapshot did not start` : codedMessage(159), ) if (replace) { subscription.replaceSnapshots( segment.where, (segment.replaces ?? []).map((replaced) => replaced.where), ) if (segment.ready instanceof Promise) { void segment.ready.then( () => this.finishReplacement(subscription, plan.id, segment), () => {}, ) } else { this.finishReplacement(subscription, plan.id, segment) } } } } finally { release() } if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } } const pending = (replacement ? [replacement] : state.segments) .filter( (segment) => segment.state === `pending` && intersects(segment.keys, nextKeys), ) .map((segment) => segment.ready) .filter((ready) => ready instanceof Promise) return { changed: true, empty: false, ready: pending.length > 0 ? Promise.all(pending) : true, } } // The outcome of a segment the plan still holds decides its needed keys: a // success (no `error`) loaded them, and a failure leaves them missing, // unless a later segment loads the key (law 10: a key's latest load // decides it). A released segment's outcome decides nothing. private recordOutcome( planId: string, segment: DemandSegment, error?: Error, ): void { const state = this.states.get(planId) if (!state?.segments.includes(segment)) return const later = state.segments.slice(state.segments.indexOf(segment) + 1) for (const key of segment.keys.keys()) if ( state.keys.has(key) && !(error && later.some((loader) => loader.keys.has(key))) ) this.onKeyOutcome?.(planId, key, error) } clear(): void { for (const state of this.states.values()) { for (const segment of state.segments) segment.abortController.abort() } this.states.clear() this.warnedPlans.clear() this.valueIdentity = createValueIdentity() } hasPendingDemand(planId: string): boolean { return ( this.states .get(planId) ?.segments.some((segment) => segment.state === `pending`) ?? false ) } private finishReplacement( subscription: CollectionSubscription, planId: string, replacement: DemandSegment, ): void { const state = this.states.get(planId) if ( !state?.segments.includes(replacement) || !equalKeySets(replacement.keys, state.keys) ) { releaseSegments(subscription, [replacement]) return } const replaced = replacement.replaces ?? [] replacement.replaces = undefined state.segments = [replacement] releaseSegments(subscription, replaced) } private warnUnoptimized(plan: LazyDemandPlan): void { if (!(devBuild() && process.env.NODE_ENV !== `production`)) return if (this.warnedPlans.has(plan.id)) return this.warnedPlans.add(plan.id) const path = plan.path.join(`.`) console.warn( `[TanStack DB]${plan.collectionId ? ` [${plan.collectionId}]` : ``} Join requires an index on "${path}" for efficient loading. ` + `Falling back to scanning local data. ` + `Consider creating an index on the collection with collection.createIndex((row) => row.${path}) ` + `or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`, ) } } /** An ordered filter waits only for demand on its joined source. */ export function hasPendingJoinedWork( joinedSourceId: string, lazySourcesCallbacks: Record<string, LazyCollectionCallbacks>, subscriptions: Record<string, CollectionSubscription>, isPendingPlan: (planId: string) => boolean, ): boolean { return ( (lazySourcesCallbacks[joinedSourceId]?.plans?.some((plan) => isPendingPlan(plan.id), ) ?? false) || subscriptions[joinedSourceId]?.status === `loadingSubset` ) } function canonicalizeKeys( keys: Set<unknown>, valueIdentity: ValueIdentity, ): Map<string, unknown> { return new Map( [...keys].map((key) => [valueIdentity.serializeEquality(key), key]), ) } function equalKeySets( left: Map<string, unknown>, right: Map<string, unknown>, ): boolean { return ( left.size === right.size && [...left.keys()].every((key) => right.has(key)) ) } function intersects( left: Map<string, unknown>, right: Map<string, unknown>, ): boolean { return [...left.keys()].some((key) => right.has(key)) } function createSegment( plan: LazyDemandPlan, keys: Map<string, unknown>, ): DemandSegment { const where = inArray(new PropRef(plan.path), [...keys.values()]) return { keys, where, abortController: new AbortController(), ready: true, state: `starting`, } } // An abort ends the segment's load: its outcome decides nothing. A replay // that aborted it covers its keys, so the load's consumers wait for that // replay instead. While a truncate's or a reload's replay is judged, its // verdict decides the outcome of every load that settles, so the load // decides no request error itself and does not fail the query's first load. function startSegment( subscription: CollectionSubscription, segment: DemandSegment, onUnoptimized: () => void, onOutcome: (error?: Error) => void, ): boolean { let observed = false const requested = subscription.requestSnapshot({ where: segment.where, signal: segment.abortController.signal, trackLoadSubsetPromise: false, onUnoptimized, onLoadSubsetResult: (result, options) => { observed = true if (!(result instanceof Promise)) { segment.state = `settled` return } segment.state = `pending` const settle = (failure?: { error: unknown }): Promise<void> | undefined => { if (options.signal?.aborted) return replayed(subscription).then(() => { segment.state = `settled` }) segment.state = failure ? `failed` : `settled` if (subscription.pendingTruncateReplacement) return undefined const error = failure && normalizeError(failure.error) onOutcome(error) if (error) throw error return undefined } segment.ready = result.then( () => settle(), (error: unknown) => settle({ error }), ) }, }) return requested && observed } // Settles once no truncate's or reload's replay is pending, also when a // newer one replaces the pending one. function replayed(subscription: CollectionSubscription): Promise<void> { const replay = subscription.pendingTruncateReplacement if (!replay) return Promise.resolve() const next = () => replayed(subscription) return replay.then(next, next) } // One demand change: the subscription releases every segment even if // subscriber code that a release runs throws, then reports the first error. function releaseSegments( subscription: CollectionSubscription, segments: Array<DemandSegment>, ): void { if (segments.length === 0) return for (const segment of segments) segment.abortController.abort() subscription.releaseSnapshot(...segments.map((segment) => segment.where)) }