UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

284 lines (262 loc) • 8.49 kB
import { inArray } from '../builder/functions.js' import { PropRef } from '../ir.js' import { createValueIdentity } from '../equality-value-identity.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' import type { LoadSubsetRequestResult } from '../../types.js' type DemandSegment = { keys: Map<string, unknown> where: BasicExpression<boolean> abortController: AbortController ready: LoadSubsetRequestResult 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>() private readonly warnedPlans = new Set<string>() private valueIdentity = createValueIdentity() 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) for (const segment of retired) releaseSegment(subscription, segment) if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } } if (nextKeys.size === 0) { this.states.delete(plan.id) 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 if (requestedKeys.size > 0) { const segment = createSegment(plan, requestedKeys) if (replace) segment.replaces = [...segments] segments.push(segment) const started = startSegment(subscription, segment, () => this.warnUnoptimized(plan), ) if (this.states.get(plan.id) !== state) { return { changed: false, empty: false, ready: true } } if (!started) throw new Error(`Subset demand snapshot did not start`) if (replace) { if (segment.ready instanceof Promise) { void segment.ready.then( () => this.finishReplacement(subscription, plan.id, segment), () => {}, ) } else { this.finishReplacement(subscription, plan.id, segment) } } } const pending = state.segments .filter( (segment) => segment.state === `pending` && intersects(segment.keys, nextKeys), ) .map((segment) => segment.ready) .filter((ready): ready is Promise<void> => ready instanceof Promise) return { changed: true, empty: false, ready: pending.length > 0 ? Promise.all(pending) : true, } } 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) ) { releaseSegment(subscription, replacement) return } const replaced = replacement.replaces ?? [] replacement.replaces = undefined state.segments = [replacement] for (const segment of replaced) releaseSegment(subscription, segment) } private warnUnoptimized(plan: LazyDemandPlan): void { 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`, } } function startSegment( subscription: CollectionSubscription, segment: DemandSegment, onUnoptimized: () => void, ): boolean { let observed = false const requested = subscription.requestSnapshot({ where: segment.where, signal: segment.abortController.signal, trackLoadSubsetPromise: false, onUnoptimized, onLoadSubsetResult: (result) => { observed = true segment.ready = result segment.state = result instanceof Promise ? `pending` : `settled` }, }) if (segment.ready instanceof Promise) { void segment.ready.then( () => { segment.state = `settled` }, () => { segment.state = `failed` }, ) } return requested && observed } function releaseSegment( subscription: CollectionSubscription, segment: DemandSegment, ): void { segment.abortController.abort() try { subscription.releaseSnapshot(segment.where) } catch { // CollectionSubscription has already reported the one cleanup failure. } }