UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

716 lines (664 loc) • 24.1 kB
import { SortedMap } from '../SortedMap.js' import { CleanupQueue } from '../collection/cleanup-queue.js' import { UNSUBSCRIBED_GC_FLOOR_MS } from '../collection/lifecycle.js' import { makeComparator } from '../utils/comparison.js' import { isVirtualPropName } from '../virtual-props.js' import { getPersistedReadinessSource } from '../persisted-readiness.js' import { getWhereExpression } from './ir.js' import { equalityConjunct, equalityKey, readPath } from './equality-conjunct.js' import { compileExpression, toBooleanPredicate } from './compiler/evaluators.js' import { buildCompareOptions } from './compiler/order-by.js' import { createLiveQueryCollection } from './live-query-collection.js' import type { BasicExpression, OrderBy, QueryIR } from './ir.js' import type { BaseQueryBuilder } from './builder/index.js' import type { Collection, CollectionImpl } from '../collection/index.js' import type { ChangeMessage, CollectionStatus } from '../types.js' import type { CollectionEventHandler, CollectionStatusChangeEvent, } from '../collection/events.js' /** * Live queries that filter one source Collection only by `eq(field, literal)` * share one partition of that source per filtered field set. Each query reads * the group for its literal tuple, so mounting many queries of one shape * costs a lookup each instead of a compiled graph and a source subscription. * * A group holds the rows the partition's source subscription has published, * keyed by the same normalized equality that `eq` uses: a Date equals its * timestamp, `NaN` equals `NaN`, `-0` equals `0`, and nullish values match no * literal. */ type Row = Record<string, unknown> type Listener = (changes: Array<ChangeMessage<Row, string | number>>) => void type StatusListener = CollectionEventHandler<`status:change`> interface PartitionGroup { key: string // Key order, as in a live-query Collection without orderBy. rows: SortedMap<string | number, Row> listeners: Set<Listener> revision: number layoutRevision: number } const partitionsBySource = new WeakMap<object, Map<string, Partition>>() // What a view reads for a value with no rows and no watchers. Real groups // draw revisions from their partition's clock, which starts above 0. const EMPTY_GROUP: PartitionGroup = { key: ``, rows: new SortedMap(), listeners: new Set(), revision: 0, layoutRevision: 0, } // Length-prefixed, so no two part lists share an encoding. function appendGroupKeyPart(groupKey: string, part: string): string { return `${groupKey}${part.length}:${part}` } class Partition { private readonly groups = new Map<string, PartitionGroup>() // Revisions for every group, so a recreated group never repeats one. private clock = 0 private subscription: { unsubscribe: () => void } | undefined private stopStatusEvents: (() => void) | undefined // One source status listener serves every view of this partition. readonly statusListeners = new Set<StatusListener>() private listenerCount = 0 private gcTime = 0 /** Set when the source starts cleanup; groups keep their last rows. */ terminated = false constructor( private readonly source: CollectionImpl<any, any, any, any, any>, private readonly paths: Array<Array<string>>, // Row order for an `orderBy` shape; key order otherwise. private readonly compareRows: ((a: Row, b: Row) => number) | undefined, // The partition's entry in its source's map, which a mount looks up. private readonly registry: { add: () => void; remove: () => void }, ) {} groupKeyOf(row: Row | undefined): string | undefined { if (row === undefined) return undefined let groupKey = `` for (const path of this.paths) { const key = equalityKey(readPath(row, path)) if (key === undefined) return undefined groupKey = appendGroupKeyPart(groupKey, key) } return groupKey } // Whether two versions of a row hold the same value in every field. private sameFields(a: Row, b: Row): boolean { return this.paths.every((path) => Object.is(readPath(a, path), readPath(b, path)), ) } group(key: string): PartitionGroup { let group = this.groups.get(key) if (!group) { const revision = ++this.clock group = { key, rows: new SortedMap(this.compareRows), listeners: new Set(), revision, layoutRevision: revision, } this.groups.set(key, group) } return group } /** A group for reading, without creating one. */ peek(key: string): PartitionGroup { return this.groups.get(key) ?? EMPTY_GROUP } // A group with no rows and no watchers holds nothing anyone can read. private dropIfUnused(group: PartitionGroup): void { if (group.rows.size === 0 && group.listeners.size === 0) { this.groups.delete(group.key) } } /** * Keep the shared source subscription open. Each view brings its query's * `gcTime`; the partition keeps the longest, so it never releases before * one of its views' own live-query Collection would have. */ retain(gcTime?: number): void { if (gcTime !== undefined) { // As for a Collection, a non-positive or non-finite gcTime disables GC. const delay = gcTime > 0 && Number.isFinite(gcTime) ? gcTime : Infinity this.gcTime = Math.max(this.gcTime, delay) } if (this.terminated) return this.subscribe() // Like a Collection that synced before anything subscribed, a view built // during a render gets a grace period to subscribe when it commits. this.scheduleRelease(UNSUBSCRIBED_GC_FLOOR_MS) } subscribe(): void { if (!this.terminated && !this.subscription) { // A released partition that subscribes again serves new mounts too. this.registry.add() this.subscription = this.source.subscribeChanges( (changes) => this.apply(changes as Array<ChangeMessage<Row, string | number>>), { includeInitialState: true }, ) const stopStatus = this.source.on(`status:change`, (event) => { this.deliverStatus(event) }) // Like a live query, a pooled view fails for good when its source // starts cleanup, before the adapter's cleanup settles; queries // mounted later get a new partition. const stopCleanupStart = this.source._onCleanupStart(() => { const previousStatus = this.source.status this.terminate() this.deliverStatus({ type: `status:change`, collection: this.source as unknown as Collection, previousStatus, status: `error`, }) }) this.stopStatusEvents = () => { stopStatus() stopCleanupStart() } } } private deliverStatus(event: CollectionStatusChangeEvent): void { const delivered = this.terminated ? { ...event, status: `error` as const } : event for (const listener of [...this.statusListeners]) listener(delivered) if (this.terminated) { this.stopStatusEvents?.() this.stopStatusEvents = undefined } } addListener(group: PartitionGroup, listener: Listener): void { this.subscribe() group.listeners.add(listener) this.listenerCount++ } removeListener(group: PartitionGroup, listener: Listener): void { if (!group.listeners.delete(listener)) return this.listenerCount-- this.dropIfUnused(group) this.scheduleRelease(0) } private terminate(): void { this.terminated = true CleanupQueue.getInstance().cancel(this) this.release() } private release(): void { this.subscription?.unsubscribe() this.subscription = undefined this.registry.remove() } // Releases on the Collections' shared GC queue, after the longest // `gcTime` of this partition's views. private scheduleRelease(minDelay: number): void { if (this.listenerCount > 0 || !Number.isFinite(this.gcTime)) return const delay = Math.max(this.gcTime, minDelay) CleanupQueue.getInstance().schedule(this, delay, this.releaseIfUnused) } private readonly releaseIfUnused = (): void => { if (this.listenerCount > 0) return this.stopStatusEvents?.() this.stopStatusEvents = undefined // Views read groups by key, so a later subscription refills new ones. this.groups.clear() this.release() } private apply(changes: Array<ChangeMessage<Row, string | number>>): void { const touched = new Map< PartitionGroup, Array<ChangeMessage<Row, string | number>> >() const record = ( group: PartitionGroup, change: ChangeMessage<Row, string | number>, ) => { const list = touched.get(group) if (list) list.push(change) else touched.set(group, [change]) if (change.type !== `update`) group.layoutRevision = ++this.clock } for (const change of changes) { const next = change.type === `delete` ? undefined : this.groupKeyOf(change.value) const previous = change.type === `insert` ? undefined : change.type === `update` && change.previousValue !== undefined && this.sameFields(change.value, change.previousValue) ? next : this.groupKeyOf( change.type === `delete` ? change.value : change.previousValue, ) if (previous !== undefined && previous !== next) { const group = this.group(previous) const old = group.rows.get(change.key) if (group.rows.delete(change.key)) { record(group, { type: `delete`, key: change.key, value: old! }) } } if (next !== undefined) { const group = this.group(next) const existed = group.rows.has(change.key) group.rows.set(change.key, change.value) record( group, existed ? change.type === `update` ? change : { ...change, type: `update` } : { type: `insert`, key: change.key, value: change.value }, ) } } for (const [group, groupChanges] of touched) { group.revision = ++this.clock for (const listener of [...group.listeners]) listener(groupChanges) this.dropIfUnused(group) } } } type Conjunct = { path: Array<string>; pathKey: string; literalKey: string } type PoolableShape = { paths: Array<Array<string>> shapeKey: string groupKey: string // Conjuncts each view evaluates over its group's rows. residual: Array<BasicExpression> orderBy: OrderBy | undefined } // Whether an expression reads only this query's own row fields, so a view // can evaluate it with the compiler's evaluator. function readsOnlyRow(expression: BasicExpression, alias: string): boolean { if (expression.type === `val`) return true if (expression.type === `ref`) { const [root, field] = expression.path return root === alias && field !== undefined && !isVirtualPropName(field) } return expression.args.every((arg) => readsOnlyRow(arg, alias)) } // Splits a conjunct into `eq(alias.field, literal)` groups and residual // conjuncts; false for an expression a view cannot evaluate. function collectConjuncts( expression: BasicExpression, alias: string, out: Array<Conjunct>, residual: Array<BasicExpression>, ): boolean { if (expression.type === `func` && expression.name === `and`) { for (const arg of expression.args) { if (!collectConjuncts(arg, alias, out, residual)) return false } return true } const conjunct = equalityConjunctOf(expression, alias) if (conjunct) out.push(conjunct) else if (readsOnlyRow(expression, alias)) residual.push(expression) else return false return true } function equalityConjunctOf( expression: BasicExpression, alias: string, ): Conjunct | undefined { const conjunct = equalityConjunct(expression, (ref) => ref.path[0] === alias ? ref.path.slice(1) : undefined, ) return conjunct && { ...conjunct, pathKey: JSON.stringify(conjunct.path) } } // Whether a row passes every residual conjunct, as a WHERE filter decides. function rowPredicate( residual: Array<BasicExpression>, alias: string, ): (row: Row) => boolean { const conjuncts = residual.map((expression) => compileExpression(expression)) // One namespaced row, reused so a check allocates nothing. const namespaced: Record<string, unknown> = {} return (row) => { namespaced[alias] = row return conjuncts.every((conjunct) => toBooleanPredicate(conjunct(namespaced as any)), ) } } /** * The equality conjuncts of a query that a partition can serve, or undefined * when any other clause or operand is present. */ function poolableShape(query: QueryIR): PoolableShape | undefined { if ( query.from.type !== `collectionRef` || query.select || query.join || query.groupBy || query.having || query.limit !== undefined || query.offset !== undefined || query.distinct || query.singleResult || query.fnSelect || query.fnWhere?.length || query.fnHaving?.length || !query.where?.length ) { return undefined } const conjuncts: Array<Conjunct> = [] const residual: Array<BasicExpression> = [] for (const where of query.where) { if ( !collectConjuncts( getWhereExpression(where), query.from.alias, conjuncts, residual, ) ) { return undefined } } // A partition needs at least one equality to group by. if (conjuncts.length === 0) return undefined const orderBy = query.orderBy?.length ? query.orderBy : undefined const orderKey = orderBy ? orderByKey(orderBy, query.from.alias, query.from.collection) : `` if (orderKey === undefined) return undefined // Most shapes have one or two fields; a general sort costs more than both. if (conjuncts.length === 2) { if (conjuncts[1]!.pathKey < conjuncts[0]!.pathKey) conjuncts.reverse() } else if (conjuncts.length > 2) { conjuncts.sort((a, b) => (a.pathKey < b.pathKey ? -1 : 1)) } const paths: Array<Array<string>> = [] let shapeKey = `` let groupKey = `` for (const { path, pathKey, literalKey } of conjuncts) { paths.push(path) // Each JSON path delimits itself, so concatenation stays unambiguous. shapeKey += pathKey groupKey = appendGroupKeyPart(groupKey, literalKey) } // Groups of one shape share a row order, so the order is part of it. if (orderKey) shapeKey += `|${orderKey}` return { paths, shapeKey, groupKey, residual, orderBy } } // A key for an `orderBy` over this query's own row fields, or undefined for // one a partition cannot share by value, such as a custom string comparator. function orderByKey( orderBy: OrderBy, alias: string, source: CollectionImpl<any, any, any, any, any>, ): string | undefined { let key = `` for (const clause of orderBy) { const { expression } = clause if ( expression.type !== `ref` || !readsOnlyRow(expression, alias) || expression.path.length < 2 ) { return undefined } const options = buildCompareOptions(clause, source) if (options.stringSort === `custom`) return undefined key += JSON.stringify([expression.path.slice(1), options]) } return key } // Orders rows as a live-query Collection's `orderBy` does; the group's // SortedMap breaks ties by key. function rowComparator( orderBy: OrderBy, alias: string, source: CollectionImpl<any, any, any, any, any>, ): (a: Row, b: Row) => number { const terms = orderBy.map((clause) => ({ read: compileExpression(clause.expression), compare: makeComparator(buildCompareOptions(clause, source)), })) // One namespaced row, reused so a comparison allocates nothing. const namespaced: Record<string, unknown> = {} return (a, b) => { for (const { read, compare } of terms) { namespaced[alias] = a const left = read(namespaced as any) namespaced[alias] = b const result = compare(left, read(namespaced as any)) if (result !== 0) return result } return 0 } } /** * One query's view of its group, read by the live-query observer. Users get * `publicCollection` instead, which builds the query's live-query Collection * on first use and forwards every member to it. */ class PooledLiveQuery { readonly isLoadingSubset = false // No persisted readiness, single-result config, or layout channel. readonly config = undefined readonly _subscribeLayoutChanges = undefined private collection: Collection<any, any, any> | undefined = undefined private listenerCount = 0 private collectionHold: { unsubscribe: () => void } | undefined = undefined constructor( private readonly source: CollectionImpl<any, any, any, any, any>, private readonly query: BaseQueryBuilder, private readonly partition: Partition, private readonly groupKey: string, private readonly gcTime: number, // The query's conjuncts beyond its group's equalities, if any. private readonly passes: ((row: Row) => boolean) | undefined, ) { partition.retain(gcTime) } get status(): CollectionStatus { return this.partition.terminated ? `error` : this.source.status } // Read by key: the partition drops a group nobody watches once it empties. private get group(): PartitionGroup { return this.partition.peek(this.groupKey) } get _stateRevision(): number { return this.group.revision } get _layoutRevision(): number { return this.group.layoutRevision } entries(): Iterable<[string | number, Row]> { const rows = this.group.rows.entries() const passes = this.passes return passes ? [...rows].filter(([, row]) => passes(row)) : rows } subscribeChanges( callback: Listener, options: { includeInitialState?: boolean } = {}, ): { unsubscribe: () => void } { // A released partition refills its group here, so seed the filter after. this.partition.subscribe() const listener = this.passes ? this.filterChanges(callback) : callback const group = this.partition.group(this.groupKey) this.partition.addListener(group, listener) this.listenerCount++ this.holdCollection() if (options.includeInitialState) { callback( Array.from(this.entries(), ([key, value]) => ({ type: `insert`, key, value, })), ) } let subscribed = true return { unsubscribe: () => { if (!subscribed) return subscribed = false this.partition.removeListener(group, listener) if (--this.listenerCount === 0) { this.collectionHold?.unsubscribe() this.collectionHold = undefined } }, } } // While the view is observed, its built Collection stays subscribed, as // the Collection would be if it served the observer itself. private holdCollection(): void { if (this.collection && this.listenerCount > 0) { this.collectionHold ??= this.collection.subscribeChanges(() => {}) } } // Turns the group's changes into this query's, tracking which rows have // passed its residual conjuncts for this subscription. private filterChanges(callback: Listener): Listener { const passes = this.passes! const visible = new Map(this.entries()) return (changes) => { const out: Array<ChangeMessage<Row, string | number>> = [] for (const change of changes) { const { key, value } = change const previous = visible.get(key) const next = change.type !== `delete` && passes(value) if (next) visible.set(key, value) else visible.delete(key) if (previous && next) { out.push({ type: `update`, key, value, previousValue: previous }) } else if (previous) { out.push({ type: `delete`, key, value: previous }) } else if (next) { out.push({ type: `insert`, key, value }) } } if (out.length > 0) callback(out) } } on(...args: Parameters<CollectionImpl[`on`]>): () => void { const [event, listener] = args if (event !== `status:change`) return this.source.on(...args) const listeners = this.partition.statusListeners listeners.add(listener as StatusListener) return () => listeners.delete(listener as StatusListener) } preload(): Promise<void> { return this.source.preload() } cleanup(): Promise<void> { return this.collection?.cleanup() ?? Promise.resolve() } private proxy: Collection<any, any, any> | undefined = undefined /** This view as the Collection it stands in for. */ get publicCollection(): Collection<any, any, any> { return (this.proxy ??= new Proxy( this, forwardToCollection, ) as unknown as Collection<any, any, any>) } materialize(): Collection<any, any, any> { if (!this.collection) { this.collection = createLiveQueryCollection({ query: this.query, startSync: true, gcTime: this.gcTime, }) this.holdCollection() } return this.collection } } // The observer reads the view itself; users get the live-query Collection. const forwardToCollection: ProxyHandler<PooledLiveQuery> = { get(view, property) { const collection = view.materialize() const value = Reflect.get(collection, property, collection) return typeof value === `function` ? value.bind(collection) : value }, has(view, property) { return Reflect.has(view.materialize(), property) }, // So `instanceof` and query sources accept it as the Collection it is. getPrototypeOf(view) { return Reflect.getPrototypeOf(view.materialize()) }, } /** * The identity of a query a partition can serve with no residual conjunct: * its source and its `eq` fields and literals, which determine its rows. * Undefined for any other query, which keeps the full structural identity. */ export function getPooledQueryIdentity( query: BaseQueryBuilder, ): string | undefined { const ir = query._getQuery() if (ir.from.type !== `collectionRef`) return undefined const shape = poolableShape(ir) if (!shape || shape.residual.length > 0) return undefined return JSON.stringify([ir.from.collection.id, shape.shapeKey, shape.groupKey]) } /** * A pooled view for a query a partition can serve, or undefined. The view is * typed as the Collection it stands in for. */ export function createPooledLiveQuery( query: BaseQueryBuilder, // A live-query Collection's default when the adapter gives none. { gcTime = 5_000 }: { gcTime?: number } = {}, ): Collection<any, any, any> | undefined { const ir = query._getQuery() const shape = poolableShape(ir) if (!shape || ir.from.type !== `collectionRef`) return undefined const source = ir.from.collection // Persisted restore and on-demand loading need the live-query Collection. if ( source.config.syncMode === `on-demand` || getPersistedReadinessSource(source.config) ) { return undefined } let partitions = partitionsBySource.get(source) if (!partitions) { partitions = new Map() partitionsBySource.set(source, partitions) } const { shapeKey } = shape let partition = partitions.get(shapeKey) if (!partition) { const owner = partitions // A released partition may subscribe again; it must not then replace or // remove a newer partition created under its key. const created: Partition = new Partition( source, shape.paths, shape.orderBy && rowComparator(shape.orderBy, ir.from.alias, source), { add: () => { if (!owner.has(shapeKey)) owner.set(shapeKey, created) }, remove: () => { if (owner.get(shapeKey) === created) owner.delete(shapeKey) }, }, ) partition = created partitions.set(shapeKey, partition) } // Observers read the view directly; users get its `publicCollection`. return new PooledLiveQuery( source, query, partition, shape.groupKey, gcTime, shape.residual.length > 0 ? rowPredicate(shape.residual, ir.from.alias) : undefined, ) as unknown as Collection<any, any, any> }