UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,149 lines (1,148 loc) 40.7 kB
import { D2, output, serializeValue } from "@tanstack/db-ivm"; import { compileQuery, INCLUDES_ROUTING } from "../compiler/index.js"; import { createCollection } from "../../collection/index.js"; import { SetWindowRequiresOrderByError, MissingAliasInputsError } from "../../errors.js"; import { transactionScopedScheduler } from "../../scheduler.js"; import { getActiveTransaction } from "../../transactions.js"; import { CollectionSubscriber } from "./collection-subscriber.js"; import { getCollectionBuilder } from "./collection-registry.js"; import { LIVE_QUERY_INTERNAL } from "./internal.js"; import { buildQueryFromConfig, extractCollectionsFromQuery, extractCollectionAliases, extractCollectionFromSource } from "./utils.js"; let liveQueryCollectionCounter = 0; class CollectionConfigBuilder { constructor(config) { this.config = config; this.compiledAliasToCollectionId = {}; this.resultKeys = /* @__PURE__ */ new WeakMap(); this.orderByIndices = /* @__PURE__ */ new WeakMap(); this.isGraphRunning = false; this.runCount = 0; this.isInErrorState = false; this.aliasDependencies = {}; this.builderDependencies = /* @__PURE__ */ new Set(); this.pendingGraphRuns = /* @__PURE__ */ new Map(); this.subscriptions = {}; this.lazySourcesCallbacks = {}; this.lazySources = /* @__PURE__ */ new Set(); this.optimizableOrderByCollections = {}; this.id = config.id || `live-query-${++liveQueryCollectionCounter}`; this.query = buildQueryFromConfig({ query: config.query, requireObjectResult: true }); this.collections = extractCollectionsFromQuery(this.query); const collectionAliasesById = extractCollectionAliases(this.query); this.collectionByAlias = {}; for (const [collectionId, aliases] of collectionAliasesById.entries()) { const collection = this.collections[collectionId]; if (!collection) continue; for (const alias of aliases) { this.collectionByAlias[alias] = collection; } } if (this.query.orderBy && this.query.orderBy.length > 0) { this.compare = createOrderByComparator(this.orderByIndices); } this.compareOptions = this.config.defaultStringCollation ?? extractCollectionFromSource(this.query).compareOptions; this.compileBasePipeline(); } /** * Recursively checks if a query or any of its subqueries contains joins */ hasJoins(query) { if (query.join && query.join.length > 0) { return true; } if (query.from.type === `queryRef`) { if (this.hasJoins(query.from.query)) { return true; } } return false; } getConfig() { return { id: this.id, getKey: this.config.getKey || ((item) => this.resultKeys.get(item) ?? item.$key), sync: this.getSyncConfig(), compare: this.compare, defaultStringCollation: this.compareOptions, gcTime: this.config.gcTime || 5e3, // 5 seconds by default for live queries schema: this.config.schema, onInsert: this.config.onInsert, onUpdate: this.config.onUpdate, onDelete: this.config.onDelete, startSync: this.config.startSync, singleResult: this.query.singleResult, utils: { getRunCount: this.getRunCount.bind(this), setWindow: this.setWindow.bind(this), getWindow: this.getWindow.bind(this), [LIVE_QUERY_INTERNAL]: { getBuilder: () => this, hasCustomGetKey: !!this.config.getKey, hasJoins: this.hasJoins(this.query), hasDistinct: !!this.query.distinct } } }; } setWindow(options) { if (!this.windowFn) { throw new SetWindowRequiresOrderByError(); } this.currentWindow = options; this.windowFn(options); this.maybeRunGraphFn?.(); if (this.liveQueryCollection?.isLoadingSubset) { return new Promise((resolve) => { const unsubscribe = this.liveQueryCollection.on( `loadingSubset:change`, (event) => { if (!event.isLoadingSubset) { unsubscribe(); resolve(); } } ); }); } return true; } getWindow() { if (!this.windowFn || !this.currentWindow) { return void 0; } return { offset: this.currentWindow.offset ?? 0, limit: this.currentWindow.limit ?? 0 }; } /** * Resolves a collection alias to its collection ID. * * Uses a two-tier lookup strategy: * 1. First checks compiled aliases (includes subquery inner aliases) * 2. Falls back to declared aliases from the query's from/join clauses * * @param alias - The alias to resolve (e.g., "employee", "manager") * @returns The collection ID that the alias references * @throws {Error} If the alias is not found in either lookup */ getCollectionIdForAlias(alias) { const compiled = this.compiledAliasToCollectionId[alias]; if (compiled) { return compiled; } const collection = this.collectionByAlias[alias]; if (collection) { return collection.id; } throw new Error(`Unknown source alias "${alias}"`); } isLazyAlias(alias) { return this.lazySources.has(alias); } // The callback function is called after the graph has run. // This gives the callback a chance to load more data if needed, // that's used to optimize orderBy operators that set a limit, // in order to load some more data if we still don't have enough rows after the pipeline has run. // That can happen because even though we load N rows, the pipeline might filter some of these rows out // causing the orderBy operator to receive less than N rows or even no rows at all. // So this callback would notice that it doesn't have enough rows and load some more. // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready. maybeRunGraph(callback) { if (this.isGraphRunning) { return; } if (!this.currentSyncConfig || !this.currentSyncState) { throw new Error( `maybeRunGraph called without active sync session. This should not happen.` ); } this.isGraphRunning = true; try { const { begin, commit } = this.currentSyncConfig; const syncState = this.currentSyncState; if (this.isInErrorState) { return; } if (syncState.subscribedToAllCollections) { let callbackCalled = false; while (syncState.graph.pendingWork()) { syncState.graph.run(); syncState.flushPendingChanges?.(); callback?.(); callbackCalled = true; } if (!callbackCalled) { callback?.(); } if (syncState.messagesCount === 0) { begin(); commit(); } this.updateLiveQueryStatus(this.currentSyncConfig); } } finally { this.isGraphRunning = false; } } /** * Schedules a graph run with the transaction-scoped scheduler. * Ensures each builder runs at most once per transaction, with automatic dependency tracking * to run parent queries before child queries. Outside a transaction, runs immediately. * * Multiple calls during a transaction are coalesced into a single execution. * Dependencies are auto-discovered from subscribed live queries, or can be overridden. * Load callbacks are combined when entries merge. * * Uses the current sync session's config and syncState from instance properties. * * @param callback - Optional callback to load more data if needed (returns true when done) * @param options - Optional scheduling configuration * @param options.contextId - Transaction ID to group work; defaults to active transaction * @param options.jobId - Unique identifier for this job; defaults to this builder instance * @param options.alias - Source alias that triggered this schedule; adds alias-specific dependencies * @param options.dependencies - Explicit dependency list; overrides auto-discovered dependencies */ scheduleGraphRun(callback, options) { const contextId = options?.contextId ?? getActiveTransaction()?.id; const jobId = options?.jobId ?? this; const dependentBuilders = (() => { if (options?.dependencies) { return options.dependencies; } const deps = new Set(this.builderDependencies); if (options?.alias) { const aliasDeps = this.aliasDependencies[options.alias]; if (aliasDeps) { for (const dep of aliasDeps) { deps.add(dep); } } } deps.delete(this); return Array.from(deps); })(); if (contextId) { for (const dep of dependentBuilders) { if (typeof dep.scheduleGraphRun === `function`) { dep.scheduleGraphRun(void 0, { contextId }); } } } if (!this.currentSyncConfig || !this.currentSyncState) { throw new Error( `scheduleGraphRun called without active sync session. This should not happen.` ); } let pending = contextId ? this.pendingGraphRuns.get(contextId) : void 0; if (!pending) { pending = { loadCallbacks: /* @__PURE__ */ new Set() }; if (contextId) { this.pendingGraphRuns.set(contextId, pending); } } if (callback) { pending.loadCallbacks.add(callback); } const pendingToPass = contextId ? void 0 : pending; transactionScopedScheduler.schedule({ contextId, jobId, dependencies: dependentBuilders, run: () => this.executeGraphRun(contextId, pendingToPass) }); } /** * Clears pending graph run state for a specific context. * Called when the scheduler clears a context (e.g., transaction rollback/abort). */ clearPendingGraphRun(contextId) { this.pendingGraphRuns.delete(contextId); } /** * Returns true if this builder has a pending graph run for the given context. */ hasPendingGraphRun(contextId) { return this.pendingGraphRuns.has(contextId); } /** * Executes a pending graph run. Called by the scheduler when dependencies are satisfied. * Clears the pending state BEFORE execution so that any re-schedules during the run * create fresh state and don't interfere with the current execution. * Uses instance sync state - if sync has ended, gracefully returns without executing. * * @param contextId - Optional context ID to look up pending state * @param pendingParam - For immediate execution (no context), pending state is passed directly */ executeGraphRun(contextId, pendingParam) { const pending = pendingParam ?? (contextId ? this.pendingGraphRuns.get(contextId) : void 0); if (contextId) { this.pendingGraphRuns.delete(contextId); } if (!pending) { return; } if (!this.currentSyncConfig || !this.currentSyncState) { return; } this.incrementRunCount(); const combinedLoader = () => { let allDone = true; let firstError; pending.loadCallbacks.forEach((loader) => { try { allDone = loader() && allDone; } catch (error) { allDone = false; firstError ??= error; } }); if (firstError) { throw firstError; } return allDone; }; this.maybeRunGraph(combinedLoader); } getSyncConfig() { return { rowUpdateMode: `full`, sync: this.syncFn.bind(this) }; } incrementRunCount() { this.runCount++; } getRunCount() { return this.runCount; } syncFn(config) { this.liveQueryCollection = config.collection; this.currentSyncConfig = config; const syncState = { messagesCount: 0, subscribedToAllCollections: false, unsubscribeCallbacks: /* @__PURE__ */ new Set() }; const fullSyncState = this.extendPipelineWithChangeProcessing( config, syncState ); this.currentSyncState = fullSyncState; this.unsubscribeFromSchedulerClears = transactionScopedScheduler.onClear( (contextId) => { this.clearPendingGraphRun(contextId); } ); const loadingSubsetUnsubscribe = config.collection.on( `loadingSubset:change`, (event) => { if (!event.isLoadingSubset) { this.updateLiveQueryStatus(config); } } ); syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe); const loadSubsetDataCallbacks = this.subscribeToAllCollections( config, fullSyncState ); this.maybeRunGraphFn = () => this.scheduleGraphRun(loadSubsetDataCallbacks); this.scheduleGraphRun(loadSubsetDataCallbacks); return () => { syncState.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe()); this.currentSyncConfig = void 0; this.currentSyncState = void 0; this.pendingGraphRuns.clear(); this.graphCache = void 0; this.inputsCache = void 0; this.pipelineCache = void 0; this.sourceWhereClausesCache = void 0; this.includesCache = void 0; this.lazySources.clear(); this.optimizableOrderByCollections = {}; this.lazySourcesCallbacks = {}; Object.keys(this.subscriptions).forEach( (key) => delete this.subscriptions[key] ); this.compiledAliasToCollectionId = {}; this.unsubscribeFromSchedulerClears?.(); this.unsubscribeFromSchedulerClears = void 0; }; } /** * Compiles the query pipeline with all declared aliases. */ compileBasePipeline() { this.graphCache = new D2(); this.inputsCache = Object.fromEntries( Object.keys(this.collectionByAlias).map((alias) => [ alias, this.graphCache.newInput() ]) ); const compilation = compileQuery( this.query, this.inputsCache, this.collections, this.subscriptions, this.lazySourcesCallbacks, this.lazySources, this.optimizableOrderByCollections, (windowFn) => { this.windowFn = windowFn; } ); this.pipelineCache = compilation.pipeline; this.sourceWhereClausesCache = compilation.sourceWhereClauses; this.compiledAliasToCollectionId = compilation.aliasToCollectionId; this.includesCache = compilation.includes; const missingAliases = Object.keys(this.compiledAliasToCollectionId).filter( (alias) => !Object.hasOwn(this.inputsCache, alias) ); if (missingAliases.length > 0) { throw new MissingAliasInputsError(missingAliases); } } maybeCompileBasePipeline() { if (!this.graphCache || !this.inputsCache || !this.pipelineCache) { this.compileBasePipeline(); } return { graph: this.graphCache, inputs: this.inputsCache, pipeline: this.pipelineCache }; } extendPipelineWithChangeProcessing(config, syncState) { const { begin, commit } = config; const { graph, inputs, pipeline } = this.maybeCompileBasePipeline(); let pendingChanges = /* @__PURE__ */ new Map(); pipeline.pipe( output((data) => { const messages = data.getInner(); syncState.messagesCount += messages.length; messages.reduce(accumulateChanges, pendingChanges); }) ); const includesState = this.setupIncludesOutput( this.includesCache, syncState ); syncState.flushPendingChanges = () => { const hasParentChanges = pendingChanges.size > 0; const hasChildChanges = hasPendingIncludesChanges(includesState); if (!hasParentChanges && !hasChildChanges) { return; } let changesToApply = pendingChanges; if (this.config.getKey) { const merged = /* @__PURE__ */ new Map(); for (const [, changes] of pendingChanges) { const customKey = this.config.getKey(changes.value); const existing = merged.get(customKey); if (existing) { existing.inserts += changes.inserts; existing.deletes += changes.deletes; if (changes.inserts > 0) { existing.value = changes.value; if (changes.orderByIndex !== void 0) { existing.orderByIndex = changes.orderByIndex; } } } else { merged.set(customKey, { ...changes }); } } changesToApply = merged; } if (hasParentChanges) { begin(); changesToApply.forEach(this.applyChanges.bind(this, config)); commit(); } pendingChanges = /* @__PURE__ */ new Map(); flushIncludesState( includesState, config.collection, this.id, hasParentChanges ? changesToApply : null, config ); }; graph.finalize(); syncState.graph = graph; syncState.inputs = inputs; syncState.pipeline = pipeline; return syncState; } /** * Sets up output callbacks for includes child pipelines. * Each includes entry gets its own output callback that accumulates child changes, * and a child registry that maps correlation key → child Collection. */ setupIncludesOutput(includesEntries, syncState) { if (!includesEntries || includesEntries.length === 0) { return []; } return includesEntries.map((entry) => { const state = { fieldName: entry.fieldName, childCorrelationField: entry.childCorrelationField, hasOrderBy: entry.hasOrderBy, materialization: entry.materialization, scalarField: entry.scalarField, childRegistry: /* @__PURE__ */ new Map(), pendingChildChanges: /* @__PURE__ */ new Map(), correlationToParentKeys: /* @__PURE__ */ new Map() }; entry.pipeline.pipe( output((data) => { const messages = data.getInner(); syncState.messagesCount += messages.length; for (const [[childKey, tupleData], multiplicity] of messages) { const [childResult, _orderByIndex, correlationKey, parentContext] = tupleData; const routingKey = computeRoutingKey(correlationKey, parentContext); let byChild = state.pendingChildChanges.get(routingKey); if (!byChild) { byChild = /* @__PURE__ */ new Map(); state.pendingChildChanges.set(routingKey, byChild); } const existing = byChild.get(childKey) || { deletes: 0, inserts: 0, value: childResult, orderByIndex: _orderByIndex }; if (multiplicity < 0) { existing.deletes += Math.abs(multiplicity); } else if (multiplicity > 0) { existing.inserts += multiplicity; existing.value = childResult; } byChild.set(childKey, existing); } }) ); if (entry.childCompilationResult.includes) { state.nestedSetups = setupNestedPipelines( entry.childCompilationResult.includes, syncState ); state.nestedRoutingIndex = /* @__PURE__ */ new Map(); state.nestedRoutingReverseIndex = /* @__PURE__ */ new Map(); } return state; }); } applyChanges(config, changes, key) { const { write, collection } = config; const { deletes, inserts, value, orderByIndex } = changes; this.resultKeys.set(value, key); if (orderByIndex !== void 0) { this.orderByIndices.set(value, orderByIndex); } if (inserts && deletes === 0) { write({ value, type: `insert` }); } else if ( // Insert & update(s) (updates are a delete & insert) inserts > deletes || // Just update(s) but the item is already in the collection (so // was inserted previously). inserts === deletes && collection.has(collection.getKeyFromItem(value)) ) { write({ value, type: `update` }); } else if (deletes > 0) { write({ value, type: `delete` }); } else { throw new Error( `Could not apply changes: ${JSON.stringify(changes)}. This should never happen.` ); } } /** * Handle status changes from source collections */ handleSourceStatusChange(config, collectionId, event) { const { status } = event; if (status === `error`) { this.transitionToError( `Source collection '${collectionId}' entered error state` ); return; } if (status === `cleaned-up`) { this.transitionToError( `Source collection '${collectionId}' was manually cleaned up while live query '${this.id}' depends on it. Live queries prevent automatic GC, so this was likely a manual cleanup() call.` ); return; } this.updateLiveQueryStatus(config); } /** * Update the live query status based on source collection statuses */ updateLiveQueryStatus(config) { const { markReady } = config; if (this.isInErrorState) { return; } const subscribedToAll = this.currentSyncState?.subscribedToAllCollections; const allReady = this.allCollectionsReady(); const isLoading = this.liveQueryCollection?.isLoadingSubset; if (subscribedToAll && allReady && !isLoading) { markReady(); } } /** * Transition the live query to error state */ transitionToError(message) { this.isInErrorState = true; console.error(`[Live Query Error] ${message}`); this.liveQueryCollection?._lifecycle.setStatus(`error`); } allCollectionsReady() { return Object.values(this.collections).every( (collection) => collection.isReady() ); } /** * Creates per-alias subscriptions enabling self-join support. * Each alias gets its own subscription with independent filters, even for the same collection. * Example: `{ employee: col, manager: col }` creates two separate subscriptions. */ subscribeToAllCollections(config, syncState) { const compiledAliases = Object.entries(this.compiledAliasToCollectionId); if (compiledAliases.length === 0) { throw new Error( `Compiler returned no alias metadata for query '${this.id}'. This should not happen; please report.` ); } const loaders = compiledAliases.map(([alias, collectionId]) => { const collection = this.collectionByAlias[alias] ?? this.collections[collectionId]; const dependencyBuilder = getCollectionBuilder(collection); if (dependencyBuilder && dependencyBuilder !== this) { this.aliasDependencies[alias] = [dependencyBuilder]; this.builderDependencies.add(dependencyBuilder); } else { this.aliasDependencies[alias] = []; } const collectionSubscriber = new CollectionSubscriber( alias, collectionId, collection, this ); const statusUnsubscribe = collection.on(`status:change`, (event) => { this.handleSourceStatusChange(config, collectionId, event); }); syncState.unsubscribeCallbacks.add(statusUnsubscribe); const subscription = collectionSubscriber.subscribe(); this.subscriptions[alias] = subscription; const loadMore = collectionSubscriber.loadMoreIfNeeded.bind( collectionSubscriber, subscription ); return loadMore; }); const loadSubsetDataCallbacks = () => { loaders.map((loader) => loader()); return true; }; syncState.subscribedToAllCollections = true; return loadSubsetDataCallbacks; } } function createOrderByComparator(orderByIndices) { return (val1, val2) => { const index1 = orderByIndices.get(val1); const index2 = orderByIndices.get(val2); if (index1 && index2) { if (index1 < index2) { return -1; } else if (index1 > index2) { return 1; } else { return 0; } } return 0; }; } function materializesInline(state) { return state.materialization !== `collection`; } function materializeIncludedValue(state, entry) { if (!entry) { if (state.materialization === `array`) { return []; } if (state.materialization === `concat`) { return ``; } return void 0; } if (state.materialization === `collection`) { return entry.collection; } const rows = [...entry.collection.toArray]; const values = state.scalarField ? rows.map((row) => row?.[state.scalarField]) : rows; if (state.materialization === `array`) { return values; } return values.map((value) => String(value ?? ``)).join(``); } function setupNestedPipelines(includes, syncState) { return includes.map((entry) => { const buffer = /* @__PURE__ */ new Map(); entry.pipeline.pipe( output((data) => { const messages = data.getInner(); syncState.messagesCount += messages.length; for (const [[childKey, tupleData], multiplicity] of messages) { const [childResult, _orderByIndex, correlationKey, parentContext] = tupleData; const routingKey = computeRoutingKey(correlationKey, parentContext); let byChild = buffer.get(routingKey); if (!byChild) { byChild = /* @__PURE__ */ new Map(); buffer.set(routingKey, byChild); } const existing = byChild.get(childKey) || { deletes: 0, inserts: 0, value: childResult, orderByIndex: _orderByIndex }; if (multiplicity < 0) { existing.deletes += Math.abs(multiplicity); } else if (multiplicity > 0) { existing.inserts += multiplicity; existing.value = childResult; } byChild.set(childKey, existing); } }) ); const setup = { compilationResult: entry, buffer }; if (entry.childCompilationResult.includes) { setup.nestedSetups = setupNestedPipelines( entry.childCompilationResult.includes, syncState ); } return setup; }); } function createPerEntryIncludesStates(setups) { return setups.map((setup) => { const state = { fieldName: setup.compilationResult.fieldName, childCorrelationField: setup.compilationResult.childCorrelationField, hasOrderBy: setup.compilationResult.hasOrderBy, materialization: setup.compilationResult.materialization, scalarField: setup.compilationResult.scalarField, childRegistry: /* @__PURE__ */ new Map(), pendingChildChanges: /* @__PURE__ */ new Map(), correlationToParentKeys: /* @__PURE__ */ new Map() }; if (setup.nestedSetups) { state.nestedSetups = setup.nestedSetups; state.nestedRoutingIndex = /* @__PURE__ */ new Map(); state.nestedRoutingReverseIndex = /* @__PURE__ */ new Map(); } return state; }); } function drainNestedBuffers(state) { const dirtyCorrelationKeys = /* @__PURE__ */ new Set(); if (!state.nestedSetups) return dirtyCorrelationKeys; for (let i = 0; i < state.nestedSetups.length; i++) { const setup = state.nestedSetups[i]; const toDelete = []; for (const [nestedCorrelationKey, childChanges] of setup.buffer) { const parentCorrelationKey = state.nestedRoutingIndex.get(nestedCorrelationKey); if (parentCorrelationKey === void 0) { continue; } const entry = state.childRegistry.get(parentCorrelationKey); if (!entry || !entry.includesStates) { continue; } const entryState = entry.includesStates[i]; for (const [childKey, changes] of childChanges) { let byChild = entryState.pendingChildChanges.get(nestedCorrelationKey); if (!byChild) { byChild = /* @__PURE__ */ new Map(); entryState.pendingChildChanges.set(nestedCorrelationKey, byChild); } const existing = byChild.get(childKey); if (existing) { existing.inserts += changes.inserts; existing.deletes += changes.deletes; if (changes.inserts > 0) { existing.value = changes.value; if (changes.orderByIndex !== void 0) { existing.orderByIndex = changes.orderByIndex; } } } else { byChild.set(childKey, { ...changes }); } } dirtyCorrelationKeys.add(parentCorrelationKey); toDelete.push(nestedCorrelationKey); } for (const key of toDelete) { setup.buffer.delete(key); } } return dirtyCorrelationKeys; } function updateRoutingIndex(state, correlationKey, childChanges) { if (!state.nestedSetups) return; for (const setup of state.nestedSetups) { for (const [, change] of childChanges) { if (change.inserts > 0) { const nestedRouting = change.value[INCLUDES_ROUTING]?.[setup.compilationResult.fieldName]; const nestedCorrelationKey = nestedRouting?.correlationKey; const nestedParentContext = nestedRouting?.parentContext ?? null; const nestedRoutingKey = computeRoutingKey( nestedCorrelationKey, nestedParentContext ); if (nestedCorrelationKey != null) { state.nestedRoutingIndex.set(nestedRoutingKey, correlationKey); let reverseSet = state.nestedRoutingReverseIndex.get(correlationKey); if (!reverseSet) { reverseSet = /* @__PURE__ */ new Set(); state.nestedRoutingReverseIndex.set(correlationKey, reverseSet); } reverseSet.add(nestedRoutingKey); } } else if (change.deletes > 0 && change.inserts === 0) { const nestedRouting2 = change.value[INCLUDES_ROUTING]?.[setup.compilationResult.fieldName]; const nestedCorrelationKey = nestedRouting2?.correlationKey; const nestedParentContext2 = nestedRouting2?.parentContext ?? null; const nestedRoutingKey = computeRoutingKey( nestedCorrelationKey, nestedParentContext2 ); if (nestedCorrelationKey != null) { state.nestedRoutingIndex.delete(nestedRoutingKey); const reverseSet = state.nestedRoutingReverseIndex.get(correlationKey); if (reverseSet) { reverseSet.delete(nestedRoutingKey); if (reverseSet.size === 0) { state.nestedRoutingReverseIndex.delete(correlationKey); } } } } } } } function cleanRoutingIndexOnDelete(state, correlationKey) { if (!state.nestedRoutingReverseIndex) return; const nestedKeys = state.nestedRoutingReverseIndex.get(correlationKey); if (nestedKeys) { for (const nestedKey of nestedKeys) { state.nestedRoutingIndex.delete(nestedKey); } state.nestedRoutingReverseIndex.delete(correlationKey); } } function hasNestedBufferChanges(setups) { for (const setup of setups) { if (setup.buffer.size > 0) return true; if (setup.nestedSetups && hasNestedBufferChanges(setup.nestedSetups)) return true; } return false; } function computeRoutingKey(correlationKey, parentContext) { if (parentContext == null) return correlationKey; return JSON.stringify([correlationKey, parentContext]); } function createChildCollectionEntry(parentId, fieldName, correlationKey, hasOrderBy, nestedSetups) { const resultKeys = /* @__PURE__ */ new WeakMap(); const orderByIndices = hasOrderBy ? /* @__PURE__ */ new WeakMap() : null; let syncMethods = null; const compare = orderByIndices ? createOrderByComparator(orderByIndices) : void 0; const collection = createCollection({ id: `__child-collection:${parentId}-${fieldName}-${serializeValue(correlationKey)}`, getKey: (item) => resultKeys.get(item), compare, sync: { rowUpdateMode: `full`, sync: (methods) => { syncMethods = methods; return () => { syncMethods = null; }; } }, startSync: true, gcTime: 0 }); const entry = { collection, get syncMethods() { return syncMethods; }, resultKeys, orderByIndices }; if (nestedSetups) { entry.includesStates = createPerEntryIncludesStates(nestedSetups); } return entry; } function flushIncludesState(includesState, parentCollection, parentId, parentChanges, parentSyncMethods) { for (const state of includesState) { if (parentChanges) { for (const [parentKey, changes] of parentChanges) { if (changes.inserts > 0) { const parentResult = changes.value; const routing = parentResult[INCLUDES_ROUTING]?.[state.fieldName]; const correlationKey = routing?.correlationKey; const parentContext = routing?.parentContext ?? null; const routingKey = computeRoutingKey(correlationKey, parentContext); if (correlationKey != null) { if (!state.childRegistry.has(routingKey)) { const entry = createChildCollectionEntry( parentId, state.fieldName, routingKey, state.hasOrderBy, state.nestedSetups ); state.childRegistry.set(routingKey, entry); } let parentKeys = state.correlationToParentKeys.get(routingKey); if (!parentKeys) { parentKeys = /* @__PURE__ */ new Set(); state.correlationToParentKeys.set(routingKey, parentKeys); } parentKeys.add(parentKey); const childValue = materializeIncludedValue( state, state.childRegistry.get(routingKey) ); parentResult[state.fieldName] = childValue; const storedParent = parentCollection.get(parentKey); if (storedParent && storedParent !== parentResult) { storedParent[state.fieldName] = childValue; } } } } } const affectedCorrelationKeys = materializesInline(state) ? new Set(state.pendingChildChanges.keys()) : null; const entriesWithChildChanges = /* @__PURE__ */ new Map(); if (state.pendingChildChanges.size > 0) { for (const [correlationKey, childChanges] of state.pendingChildChanges) { let entry = state.childRegistry.get(correlationKey); if (!entry) { entry = createChildCollectionEntry( parentId, state.fieldName, correlationKey, state.hasOrderBy, state.nestedSetups ); state.childRegistry.set(correlationKey, entry); } if (state.materialization === `collection`) { attachChildCollectionToParent( parentCollection, state.fieldName, correlationKey, state.correlationToParentKeys, entry.collection ); } if (entry.syncMethods) { entry.syncMethods.begin(); for (const [childKey, change] of childChanges) { entry.resultKeys.set(change.value, childKey); if (entry.orderByIndices && change.orderByIndex !== void 0) { entry.orderByIndices.set(change.value, change.orderByIndex); } if (change.inserts > 0 && change.deletes === 0) { entry.syncMethods.write({ value: change.value, type: `insert` }); } else if (change.inserts > change.deletes || change.inserts === change.deletes && entry.syncMethods.collection.has( entry.syncMethods.collection.getKeyFromItem(change.value) )) { entry.syncMethods.write({ value: change.value, type: `update` }); } else if (change.deletes > 0) { entry.syncMethods.write({ value: change.value, type: `delete` }); } } entry.syncMethods.commit(); } updateRoutingIndex(state, correlationKey, childChanges); entriesWithChildChanges.set(correlationKey, { entry, childChanges }); } state.pendingChildChanges.clear(); } const dirtyFromBuffers = drainNestedBuffers(state); for (const [, { entry, childChanges }] of entriesWithChildChanges) { if (entry.includesStates) { flushIncludesState( entry.includesStates, entry.collection, entry.collection.id, childChanges, entry.syncMethods ); } } for (const correlationKey of dirtyFromBuffers) { if (entriesWithChildChanges.has(correlationKey)) continue; const entry = state.childRegistry.get(correlationKey); if (entry?.includesStates) { flushIncludesState( entry.includesStates, entry.collection, entry.collection.id, null, entry.syncMethods ); } } const deepBufferDirty = /* @__PURE__ */ new Set(); if (state.nestedSetups) { for (const [correlationKey, entry] of state.childRegistry) { if (entriesWithChildChanges.has(correlationKey)) continue; if (dirtyFromBuffers.has(correlationKey)) continue; if (entry.includesStates && hasPendingIncludesChanges(entry.includesStates)) { flushIncludesState( entry.includesStates, entry.collection, entry.collection.id, null, entry.syncMethods ); deepBufferDirty.add(correlationKey); } } } const inlineReEmitKeys = materializesInline(state) ? /* @__PURE__ */ new Set([ ...affectedCorrelationKeys || [], ...dirtyFromBuffers, ...deepBufferDirty ]) : null; if (parentSyncMethods && inlineReEmitKeys && inlineReEmitKeys.size > 0) { const events = []; for (const correlationKey of inlineReEmitKeys) { const parentKeys = state.correlationToParentKeys.get(correlationKey); if (!parentKeys) continue; const entry = state.childRegistry.get(correlationKey); for (const parentKey of parentKeys) { const item = parentCollection.get(parentKey); if (item) { const key = parentSyncMethods.collection.getKeyFromItem(item); const previousValue = { ...item }; item[state.fieldName] = materializeIncludedValue(state, entry); events.push({ type: `update`, key, value: item, previousValue }); } } } if (events.length > 0) { const changesManager = parentCollection._changes; changesManager.emitEvents(events, true); } } if (parentChanges) { for (const [parentKey, changes] of parentChanges) { if (changes.deletes > 0 && changes.inserts === 0) { const routing = changes.value[INCLUDES_ROUTING]?.[state.fieldName]; const correlationKey = routing?.correlationKey; const parentContext = routing?.parentContext ?? null; const routingKey = computeRoutingKey(correlationKey, parentContext); if (correlationKey != null) { const parentKeys = state.correlationToParentKeys.get(routingKey); if (parentKeys) { parentKeys.delete(parentKey); if (parentKeys.size === 0) { cleanRoutingIndexOnDelete(state, routingKey); state.childRegistry.delete(routingKey); state.correlationToParentKeys.delete(routingKey); } } } } } } } if (parentChanges) { for (const [, changes] of parentChanges) { delete changes.value[INCLUDES_ROUTING]; } } } function hasPendingIncludesChanges(states) { for (const state of states) { if (state.pendingChildChanges.size > 0) return true; if (state.nestedSetups && hasNestedBufferChanges(state.nestedSetups)) return true; } return false; } function attachChildCollectionToParent(parentCollection, fieldName, correlationKey, correlationToParentKeys, childCollection) { const parentKeys = correlationToParentKeys.get(correlationKey); if (!parentKeys) return; for (const parentKey of parentKeys) { const item = parentCollection.get(parentKey); if (item) { item[fieldName] = childCollection; } } } function accumulateChanges(acc, [[key, tupleData], multiplicity]) { const [value, orderByIndex] = tupleData; const changes = acc.get(key) || { deletes: 0, inserts: 0, value, orderByIndex }; if (multiplicity < 0) { changes.deletes += Math.abs(multiplicity); } else if (multiplicity > 0) { changes.inserts += multiplicity; changes.value = value; if (orderByIndex !== void 0) { changes.orderByIndex = orderByIndex; } } acc.set(key, changes); return acc; } export { CollectionConfigBuilder }; //# sourceMappingURL=collection-config-builder.js.map