@tanstack/db
Version:
A reactive client store for building super fast apps on sync
1,149 lines (1,148 loc) • 40.7 kB
JavaScript
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