@tanstack/db
Version:
A reactive client store for building super fast apps on sync
291 lines (290 loc) • 10.7 kB
JavaScript
import { normalizeExpressionPaths } from "../compiler/expressions.js";
import { OrderedSourceLoader } from "./ordered-source-loader.js";
import { reconcileChangesForD2, sendChangesToInput, computeSubscriptionOrderByHints, splitUpdates } from "./utils.js";
import { SubsetDemandController } from "./subset-demand-controller.js";
const loadMoreCallbackSymbol = /* @__PURE__ */ Symbol.for(
`/db.collection-config-builder`
);
class CollectionSubscriber {
constructor(sourceId, alias, collection, collectionConfigBuilder) {
this.sourceId = sourceId;
this.alias = alias;
this.collection = collection;
this.collectionConfigBuilder = collectionConfigBuilder;
this.subscriptionLoadingPromises = /* @__PURE__ */ new Map();
this.sentToD2Rows = /* @__PURE__ */ new Map();
this.demand = new SubsetDemandController();
}
subscribe() {
const whereClause = this.getWhereClause();
if (whereClause) {
const whereExpression = normalizeExpressionPaths(whereClause, this.alias);
return this.subscribeToChanges(whereExpression);
}
return this.subscribeToChanges();
}
subscribeToChanges(whereExpression) {
const orderByInfo = this.getOrderByInfo();
const trackLoadResult = (result) => {
if (result instanceof Promise) {
const trackedResult = result.catch(async (error) => {
await Promise.resolve();
throw error;
});
this.collectionConfigBuilder.trackSubsetLoadPromise(trackedResult);
}
};
const onStatusChange = (event) => {
if (this.collectionConfigBuilder.isLazySource(this.sourceId)) return;
const subscription2 = event.subscription;
if (event.status === `loadingSubset`) {
this.ensureLoadingPromise(subscription2);
} else {
const deferred = this.subscriptionLoadingPromises.get(subscription2);
if (deferred) {
this.subscriptionLoadingPromises.delete(subscription2);
deferred.resolve();
}
}
};
const onLoadSubsetError = (event) => {
this.collectionConfigBuilder.recordSubsetError(
event.error,
// Lazy demand owns its fatal-error path. For eager sources, one
// successful page does not finish initial ordered refinement.
!this.collectionConfigBuilder.isLazySource(this.sourceId) && this.collectionConfigBuilder.liveQueryCollection?.status === `loading`
);
};
let subscription;
if (orderByInfo) {
subscription = this.subscribeToOrderedChanges(
whereExpression,
orderByInfo,
onStatusChange,
trackLoadResult,
onLoadSubsetError
);
} else {
const includeInitialState = (this.collection.config.syncMode !== `on-demand` || this.collectionConfigBuilder.query.limit !== 0) && !this.collectionConfigBuilder.isLazySource(this.sourceId);
subscription = this.subscribeToMatchingChanges(
whereExpression,
includeInitialState,
onStatusChange,
trackLoadResult,
onLoadSubsetError
);
this.registerSubscriptionCleanup(subscription);
}
if (!this.collectionConfigBuilder.isLazySource(this.sourceId) && subscription.status === `loadingSubset`) {
this.ensureLoadingPromise(subscription);
}
return subscription;
}
registerSubscriptionCleanup(subscription) {
const unsubscribe = () => {
const deferred = this.subscriptionLoadingPromises.get(subscription);
if (deferred) {
this.subscriptionLoadingPromises.delete(subscription);
deferred.resolve();
}
try {
this.demand.clear();
} finally {
subscription.unsubscribe();
}
};
this.collectionConfigBuilder.currentSyncState.unsubscribeCallbacks.add(
unsubscribe
);
}
setDemand(subscription, plan, keys) {
let update;
try {
update = this.demand.setDemand(subscription, plan, keys);
} catch (error) {
if (!Object.is(subscription.lastError, error)) throw error;
const isInitialSync = this.collectionConfigBuilder.liveQueryCollection?.status === `loading`;
const generation2 = this.collectionConfigBuilder.beginDemand(plan.id);
this.collectionConfigBuilder.failDemand(plan.id, generation2, error);
if (isInitialSync) throw error;
return;
}
if (!update.changed) return;
if (update.empty) {
this.collectionConfigBuilder.retireDemand(plan.id);
return;
}
const generation = this.collectionConfigBuilder.beginDemand(plan.id);
if (update.ready instanceof Promise) {
this.collectionConfigBuilder.trackSubsetLoadOperationPromise(update.ready);
void update.ready.then(
() => this.collectionConfigBuilder.settleDemand(plan.id, generation),
(error) => this.collectionConfigBuilder.failDemand(plan.id, generation, error)
);
} else {
this.collectionConfigBuilder.settleDemand(plan.id, generation);
}
}
sendChangesToPipeline(changes, callback) {
const changesArray = Array.isArray(changes) ? changes : [...changes];
const reconciledChanges = reconcileChangesForD2(
changesArray,
this.sentToD2Rows
);
const input = this.collectionConfigBuilder.currentSyncState.inputs[this.sourceId];
const sentChanges = sendChangesToInput(input, reconciledChanges);
const dataLoader = sentChanges > 0 ? callback : void 0;
this.collectionConfigBuilder.scheduleGraphRun(dataLoader);
}
subscribeToMatchingChanges(whereExpression, includeInitialState, onStatusChange, onLoadSubsetResult, onLoadSubsetError) {
const sendChanges = (changes) => {
this.sendChangesToPipeline(changes);
};
const hints = computeSubscriptionOrderByHints(
this.collectionConfigBuilder.query,
this.alias
);
const subscription = this.collection.subscribeChanges(sendChanges, {
...includeInitialState && { includeInitialState },
whereExpression,
onStatusChange,
onLoadSubsetError,
truncateReplayPublication: this.truncateReplayPublicationControl(),
orderBy: hints.orderBy,
limit: hints.limit,
onLoadSubsetResult: includeInitialState ? onLoadSubsetResult : void 0
});
return subscription;
}
subscribeToOrderedChanges(whereExpression, orderByInfo, onStatusChange, onLoadSubsetResult, onLoadSubsetError) {
const subscriptionHolder = {};
const sendChangesInRange = (changes) => {
const subscription2 = subscriptionHolder.current;
if (!subscription2) return;
const changesArray = Array.isArray(changes) ? changes : [...changes];
this.orderedLoader?.onSourceChanges(changesArray, this.sentToD2Rows);
const splittedChanges = splitUpdates(changesArray);
this.sendChangesToPipelineWithTracking(splittedChanges, subscription2);
};
const subscription = this.collection.subscribeChanges(sendChangesInRange, {
whereExpression,
onStatusChange,
onLoadSubsetError,
truncateReplayPublication: this.truncateReplayPublicationControl(() => {
const loader = this.orderedLoader;
this.collectionConfigBuilder.trackOrderedLoadPromise(
Promise.resolve().then(() => loader?.loadFullSource()),
true
);
})
});
subscriptionHolder.current = subscription;
this.registerSubscriptionCleanup(subscription);
const truncateUnsubscribe = this.collection.on(`truncate`, () => {
this.orderedLoader?.resetCursor();
});
subscription.on(`unsubscribed`, () => {
truncateUnsubscribe();
subscriptionHolder.current = void 0;
this.orderedLoader?.dispose();
this.orderedLoader = void 0;
});
this.orderedLoader = new OrderedSourceLoader(
orderByInfo,
subscription,
this.alias,
(result, holdPublication) => {
if (result instanceof Promise) {
this.collectionConfigBuilder.trackOrderedLoadPromise(
result,
holdPublication && !subscription.hasPendingTruncateReplacement
);
}
onLoadSubsetResult(result);
}
);
this.orderedLoader.start();
return subscription;
}
truncateReplayPublicationControl(onStart) {
const syncSession = this.collectionConfigBuilder.getSyncSession();
return {
start: () => {
onStart?.();
},
succeed: () => {
if (syncSession !== this.collectionConfigBuilder.getSyncSession()) {
return;
}
this.orderedLoader?.settleFullSourceReplay();
this.collectionConfigBuilder.scheduleGraphRunForSession(syncSession);
}
};
}
// This function is called by maybeRunGraph
// after each iteration of the query pipeline
// to ensure that the orderBy operator has enough data to work with
loadMoreIfNeeded(subscription) {
if (subscription.hasPendingTruncateReplacement && !this.collectionConfigBuilder.hasActiveWindowOperation()) {
return;
}
const orderByInfo = this.getOrderByInfo();
if (!orderByInfo) {
return;
}
try {
const pending = this.orderedLoader?.loadMore(
this.collectionConfigBuilder.getActiveWindowOperationGeneration()
);
if (pending) {
this.collectionConfigBuilder.trackSubsetLoadOperationPromise(pending);
}
} catch (error) {
if (!Object.is(subscription.lastError, error)) throw error;
}
}
sendChangesToPipelineWithTracking(changes, subscription) {
const orderByInfo = this.getOrderByInfo();
if (!orderByInfo) {
this.sendChangesToPipeline(changes);
return;
}
const subscriptionWithLoader = subscription;
subscriptionWithLoader[loadMoreCallbackSymbol] ??= this.loadMoreIfNeeded.bind(this, subscription);
this.sendChangesToPipeline(
changes,
subscriptionWithLoader[loadMoreCallbackSymbol]
);
}
getWhereClause() {
const sourceWhereClausesCache = this.collectionConfigBuilder.sourceWhereClausesCache;
if (!sourceWhereClausesCache) {
return void 0;
}
return sourceWhereClausesCache.get(this.sourceId);
}
getOrderByInfo() {
const info = this.collectionConfigBuilder.optimizableOrderByCollections[this.sourceId];
if (info?.sourceId === this.sourceId) {
return info;
}
return void 0;
}
ensureLoadingPromise(subscription) {
if (this.subscriptionLoadingPromises.has(subscription)) {
return;
}
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
this.subscriptionLoadingPromises.set(subscription, {
resolve
});
this.collectionConfigBuilder.trackSubsetLoadPromise(promise);
}
}
export {
CollectionSubscriber
};
//# sourceMappingURL=collection-subscriber.js.map