UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

430 lines (429 loc) 14.9 kB
import { ensureIndexForExpression } from "../indexes/auto-index.js"; import { and, eq, lt, gte } from "../query/builder/functions.js"; import { Value, PropRef } from "../query/ir.js"; import { EventEmitter } from "../event-emitter.js"; import { compileExpression } from "../query/compiler/evaluators.js"; import { buildCursor } from "../utils/cursor.js"; import { createFilteredCallback, createFilterFunctionFromExpression } from "./change-events.js"; class CollectionSubscription extends EventEmitter { constructor(collection, callback, options) { super(); this.collection = collection; this.callback = callback; this.options = options; this.loadedInitialState = false; this.skipFiltering = false; this.snapshotSent = false; this.loadedSubsets = []; this.sentKeys = /* @__PURE__ */ new Set(); this.limitedSnapshotRowCount = 0; this._status = `ready`; this.pendingLoadSubsetPromises = /* @__PURE__ */ new Set(); this.isBufferingForTruncate = false; this.truncateBuffer = []; this.pendingTruncateRefetches = /* @__PURE__ */ new Set(); if (options.onUnsubscribe) { this.on(`unsubscribed`, (event) => options.onUnsubscribe(event)); } if (options.whereExpression) { ensureIndexForExpression(options.whereExpression, this.collection); } const callbackWithSentKeysTracking = (changes) => { callback(changes); this.trackSentKeys(changes); }; this.callback = callbackWithSentKeysTracking; this.filteredCallback = options.whereExpression ? createFilteredCallback(this.callback, options) : this.callback; this.truncateCleanup = this.collection.on(`truncate`, () => { this.handleTruncate(); }); } get status() { return this._status; } /** * Handle collection truncate event by resetting state and re-requesting subsets. * This is called when the sync layer receives a must-refetch and clears all data. * * To prevent a flash of missing content, we buffer all changes (deletes from truncate * and inserts from refetch) until all loadSubset promises resolve, then emit them together. */ handleTruncate() { const subsetsToReload = [...this.loadedSubsets]; const hasLoadSubsetHandler = this.collection._sync.syncLoadSubsetFn !== null; if (subsetsToReload.length === 0 || !hasLoadSubsetHandler) { this.snapshotSent = false; this.loadedInitialState = false; this.limitedSnapshotRowCount = 0; this.lastSentKey = void 0; this.loadedSubsets = []; return; } this.isBufferingForTruncate = true; this.truncateBuffer = []; this.pendingTruncateRefetches.clear(); this.snapshotSent = false; this.loadedInitialState = false; this.limitedSnapshotRowCount = 0; this.lastSentKey = void 0; this.loadedSubsets = []; queueMicrotask(() => { if (!this.isBufferingForTruncate) { return; } for (const options of subsetsToReload) { const syncResult = this.collection._sync.loadSubset(options); this.loadedSubsets.push(options); this.trackLoadSubsetPromise(syncResult); if (syncResult instanceof Promise) { this.pendingTruncateRefetches.add(syncResult); syncResult.catch(() => { }).finally(() => { this.pendingTruncateRefetches.delete(syncResult); this.checkTruncateRefetchComplete(); }); } } if (this.pendingTruncateRefetches.size === 0) { this.flushTruncateBuffer(); } }); } /** * Check if all truncate refetch promises have completed and flush buffer if so */ checkTruncateRefetchComplete() { if (this.pendingTruncateRefetches.size === 0 && this.isBufferingForTruncate) { this.flushTruncateBuffer(); } } /** * Flush the truncate buffer, emitting all buffered changes to the callback */ flushTruncateBuffer() { this.isBufferingForTruncate = false; const merged = this.truncateBuffer.flat(); if (merged.length > 0) { this.filteredCallback(merged); } this.truncateBuffer = []; } setOrderByIndex(index) { this.orderByIndex = index; } /** * Set subscription status and emit events if changed */ setStatus(newStatus) { if (this._status === newStatus) { return; } const previousStatus = this._status; this._status = newStatus; this.emitInner(`status:change`, { type: `status:change`, subscription: this, previousStatus, status: newStatus }); const eventKey = `status:${newStatus}`; this.emitInner(eventKey, { type: eventKey, subscription: this, previousStatus, status: newStatus }); } /** * Track a loadSubset promise and manage loading status */ trackLoadSubsetPromise(syncResult) { if (syncResult instanceof Promise) { this.pendingLoadSubsetPromises.add(syncResult); this.setStatus(`loadingSubset`); syncResult.finally(() => { this.pendingLoadSubsetPromises.delete(syncResult); if (this.pendingLoadSubsetPromises.size === 0) { this.setStatus(`ready`); } }); } } hasLoadedInitialState() { return this.loadedInitialState; } hasSentAtLeastOneSnapshot() { return this.snapshotSent; } emitEvents(changes) { const newChanges = this.filterAndFlipChanges(changes); if (this.isBufferingForTruncate) { if (newChanges.length > 0) { this.truncateBuffer.push(newChanges); } } else { this.filteredCallback(newChanges); } } /** * Sends the snapshot to the callback. * Returns a boolean indicating if it succeeded. * It can only fail if there is no index to fulfill the request * and the optimizedOnly option is set to true, * or, the entire state was already loaded. */ requestSnapshot(opts) { if (this.loadedInitialState) { return false; } const stateOpts = { where: this.options.whereExpression, optimizedOnly: opts?.optimizedOnly ?? false }; if (opts) { if (`where` in opts) { const snapshotWhereExp = opts.where; if (stateOpts.where) { const subWhereExp = stateOpts.where; const combinedWhereExp = and(subWhereExp, snapshotWhereExp); stateOpts.where = combinedWhereExp; } else { stateOpts.where = snapshotWhereExp; } } } else { this.loadedInitialState = true; } const loadOptions = { where: stateOpts.where, subscription: this, // Include orderBy and limit if provided so sync layer can optimize the query orderBy: opts?.orderBy, limit: opts?.limit }; const syncResult = this.collection._sync.loadSubset(loadOptions); this.loadedSubsets.push(loadOptions); const trackLoadSubsetPromise = opts?.trackLoadSubsetPromise ?? true; if (trackLoadSubsetPromise) { this.trackLoadSubsetPromise(syncResult); } const snapshot = this.collection.currentStateAsChanges(stateOpts); if (snapshot === void 0) { return false; } const filteredSnapshot = snapshot.filter( (change) => !this.sentKeys.has(change.key) ); for (const change of filteredSnapshot) { this.sentKeys.add(change.key); } this.snapshotSent = true; this.callback(filteredSnapshot); return true; } /** * Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to the cursor. * Requires a range index to be set with `setOrderByIndex` prior to calling this method. * It uses that range index to load the items in the order of the index. * * For multi-column orderBy: * - Uses first value from `minValues` for LOCAL index operations (wide bounds, ensures no missed rows) * - Uses all `minValues` to build a precise composite cursor for SYNC layer loadSubset * * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater. * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values. * Note 2: it does not send keys that have already been sent before. */ requestLimitedSnapshot({ orderBy, limit, minValues, offset }) { if (!limit) throw new Error(`limit is required`); if (!this.orderByIndex) { throw new Error( `Ordered snapshot was requested but no index was found. You have to call setOrderByIndex before requesting an ordered snapshot.` ); } const minValue = minValues?.[0]; const minValueForIndex = minValue; const index = this.orderByIndex; const where = this.options.whereExpression; const whereFilterFn = where ? createFilterFunctionFromExpression(where) : void 0; const filterFn = (key) => { if (this.sentKeys.has(key)) { return false; } const value = this.collection.get(key); if (value === void 0) { return false; } return whereFilterFn?.(value) ?? true; }; let biggestObservedValue = minValueForIndex; const changes = []; let keys = []; if (minValueForIndex !== void 0) { const { expression } = orderBy[0]; const allRowsWithMinValue = this.collection.currentStateAsChanges({ where: eq(expression, new Value(minValueForIndex)) }); if (allRowsWithMinValue) { const keysWithMinValue = allRowsWithMinValue.map((change) => change.key).filter((key) => !this.sentKeys.has(key) && filterFn(key)); keys.push(...keysWithMinValue); const keysGreaterThanMin = index.take( limit - keys.length, minValueForIndex, filterFn ); keys.push(...keysGreaterThanMin); } else { keys = index.take(limit, minValueForIndex, filterFn); } } else { keys = index.take(limit, minValueForIndex, filterFn); } const valuesNeeded = () => Math.max(limit - changes.length, 0); const collectionExhausted = () => keys.length === 0; const orderByExpression = orderBy[0].expression; const valueExtractor = orderByExpression.type === `ref` ? compileExpression(new PropRef(orderByExpression.path), true) : null; while (valuesNeeded() > 0 && !collectionExhausted()) { const insertedKeys = /* @__PURE__ */ new Set(); for (const key of keys) { const value = this.collection.get(key); changes.push({ type: `insert`, key, value }); biggestObservedValue = valueExtractor ? valueExtractor(value) : value; insertedKeys.add(key); } keys = index.take(valuesNeeded(), biggestObservedValue, filterFn); } const currentOffset = this.limitedSnapshotRowCount; for (const change of changes) { this.sentKeys.add(change.key); } this.callback(changes); this.limitedSnapshotRowCount += changes.length; if (changes.length > 0) { this.lastSentKey = changes[changes.length - 1].key; } let cursorExpressions; if (minValues !== void 0 && minValues.length > 0) { const whereFromCursor = buildCursor(orderBy, minValues); if (whereFromCursor) { const { expression } = orderBy[0]; const minValue2 = minValues[0]; let whereCurrentCursor; if (minValue2 instanceof Date) { const minValuePlus1ms = new Date(minValue2.getTime() + 1); whereCurrentCursor = and( gte(expression, new Value(minValue2)), lt(expression, new Value(minValuePlus1ms)) ); } else { whereCurrentCursor = eq(expression, new Value(minValue2)); } cursorExpressions = { whereFrom: whereFromCursor, whereCurrent: whereCurrentCursor, lastKey: this.lastSentKey }; } } const loadOptions = { where, // Main filter only, no cursor limit, orderBy, cursor: cursorExpressions, // Cursor expressions passed separately offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset subscription: this }; const syncResult = this.collection._sync.loadSubset(loadOptions); this.loadedSubsets.push(loadOptions); this.trackLoadSubsetPromise(syncResult); } // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function // and that that also works properly (i.e. does not skip duplicate values) /** * Filters and flips changes for keys that have not been sent yet. * Deletes are filtered out for keys that have not been sent yet. * Updates are flipped into inserts for keys that have not been sent yet. * Duplicate inserts are filtered out to prevent D2 multiplicity > 1. */ filterAndFlipChanges(changes) { if (this.loadedInitialState || this.skipFiltering) { return changes; } const skipDeleteFilter = this.isBufferingForTruncate; const newChanges = []; for (const change of changes) { let newChange = change; const keyInSentKeys = this.sentKeys.has(change.key); if (!keyInSentKeys) { if (change.type === `update`) { newChange = { ...change, type: `insert`, previousValue: void 0 }; } else if (change.type === `delete`) { if (!skipDeleteFilter) { continue; } } this.sentKeys.add(change.key); } else { if (change.type === `insert`) { continue; } else if (change.type === `delete`) { this.sentKeys.delete(change.key); } } newChanges.push(newChange); } return newChanges; } trackSentKeys(changes) { if (this.loadedInitialState || this.skipFiltering) { return; } for (const change of changes) { if (change.type === `delete`) { this.sentKeys.delete(change.key); } else { this.sentKeys.add(change.key); } } } /** * Mark that the subscription should not filter any changes. * This is used when includeInitialState is explicitly set to false, * meaning the caller doesn't want initial state but does want ALL future changes. */ markAllStateAsSeen() { this.skipFiltering = true; } unsubscribe() { this.truncateCleanup?.(); this.truncateCleanup = void 0; this.isBufferingForTruncate = false; this.truncateBuffer = []; this.pendingTruncateRefetches.clear(); for (const options of this.loadedSubsets) { this.collection._sync.unloadSubset(options); } this.loadedSubsets = []; this.emitInner(`unsubscribed`, { type: `unsubscribed`, subscription: this }); this.clearListeners(); } } export { CollectionSubscription }; //# sourceMappingURL=subscription.js.map