diff --git a/.changeset/report-incremental-subset-errors.md b/.changeset/report-incremental-subset-errors.md new file mode 100644 index 000000000..805ea3c3f --- /dev/null +++ b/.changeset/report-incremental-subset-errors.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +'@tanstack/electric-db-collection': patch +'@tanstack/trailbase-db-collection': patch +--- + +Report incremental subset-load failures through subscriptions, live-query utilities, and effects while keeping cached source rows available. Recover cleanly from failed or overlapping must-refetch replays, collection cleanup, effect teardown errors, and cooperative adapter cancellation. Electric's shared-stream snapshot path still depends on upstream request identity or cancellation support to prevent rows from an aborted request from arriving before the request Promise settles. diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 0683eec7d..bff185c1a 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -119,6 +119,50 @@ Error tracking methods: - **`errorCount`**: Returns the number of consecutive sync failures. This counter is incremented only when queries fail completely (not per retry attempt) and is reset on successful queries: - **`clearError()`**: Clears the error state and triggers a refetch of the query. This method resets both `lastError` and `errorCount`: +## Incremental Subset Load Errors + +An incremental `loadSubset` failure does not discard rows that are already +available or put the shared source collection into `error`. The failure belongs +to the subscription that requested that subset: + +```ts +const subscription = todoCollection.subscribeChanges(handleChanges, { + includeInitialState: false, +}) + +subscription.on('loadSubset:error', ({ error, options }) => { + console.error('Subset failed', options, error) +}) + +subscription.requestSnapshot() + +// The most recent failure remains available for diagnostics. +console.log(subscription.lastError) +``` + +For ordered live queries, `utils.setWindow()` rejects with the same error. The +last failure is also available as `utils.lastSubsetError`, while the last +successful snapshot remains readable: + +```ts +try { + await liveTodos.utils.setWindow({ offset: 0, limit: 100 }) +} catch (error) { + console.error(liveTodos.utils.lastSubsetError) +} +``` + +Effects report subset failures through `onSourceError` and dispose because +their incremental result can no longer be kept complete. + +When a must-refetch truncate cannot reload every active subset, a subscription +keeps its last successful snapshot and reports the subset error. It discards +the incomplete replay batch, then resumes publishing ordinary source changes. +The next truncate retries every active subset. Overlapping truncates form one +atomic replay: all in-flight requests settle, the newest attempt decides the +result, and subscribers receive the replacement only when that attempt +succeeds. + ## Collection Status and Error States Collections track their status and transition between states: diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index 386b68f8a..212f3662c 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -198,6 +198,13 @@ return the fetched rows. `parseLoadSubsetOptions()` returns only `filters`, opaque backend cursor; translate or combine those expressions for your API. Return `unloadSubset` only when `loadSubset` creates an ongoing resource, such as a per-subset server subscription, that must be released. +Ownership transfers to core only when `loadSubset` returns `true` or a promise. +If it throws synchronously after partial setup, release that partial resource +before throwing; core will not call `unloadSubset` for a request that never +returned. A must-refetch can call `loadSubset` again with the same options. Each +successful return is a fresh acquisition: core releases the previous +acquisition when its replacement returns, then releases the current one when +the demand ends. ### Managing optimistic state duration diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index d51a0e799..00523a2f4 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -222,9 +222,6 @@ export class CollectionChangesManager< ) => void, options: SubscribeChangesOptions = {}, ): CollectionSubscription { - // Start sync and track subscriber - this.addSubscriber() - // Compile where callback to whereExpression if provided if (options.where && options.whereExpression) { throw new Error( @@ -240,37 +237,56 @@ export class CollectionChangesManager< whereExpression = toExpression(result) } - const subscription = new CollectionSubscription(this.collection, callback, { - ...opts, - whereExpression, - onUnsubscribe: () => { - this.removeSubscriber() - this.changeSubscriptions.delete(subscription) - }, - }) - - // Register status listener BEFORE requesting snapshot to avoid race condition. - // This ensures the listener catches all status transitions, even if the - // loadSubset promise resolves synchronously or very quickly. - if (options.onStatusChange) { - subscription.on(`status:change`, options.onStatusChange) - } + // Acquire ownership only after all fallible option validation and + // user-provided predicate compilation has completed. + this.addSubscriber() - if (options.includeInitialState) { - subscription.requestSnapshot({ - trackLoadSubsetPromise: false, - orderBy: options.orderBy, - limit: options.limit, - onLoadSubsetResult: options.onLoadSubsetResult, + let subscription: CollectionSubscription | undefined + try { + subscription = new CollectionSubscription(this.collection, callback, { + ...opts, + whereExpression, + onUnsubscribe: () => { + this.removeSubscriber() + if (subscription) this.changeSubscriptions.delete(subscription) + }, }) - } else if (options.includeInitialState === false) { - // When explicitly set to false (not just undefined), mark all state as "seen" - // so that all future changes (including deletes) pass through unfiltered. - subscription.markAllStateAsSeen() - } - // Add to batched listeners - this.changeSubscriptions.add(subscription) + // Register status listener BEFORE requesting snapshot to avoid race condition. + // This ensures the listener catches all status transitions, even if the + // loadSubset promise resolves synchronously or very quickly. + if (options.onStatusChange) { + subscription.on(`status:change`, options.onStatusChange) + } + + if (options.includeInitialState) { + subscription.requestSnapshot({ + trackLoadSubsetPromise: false, + orderBy: options.orderBy, + limit: options.limit, + onLoadSubsetResult: options.onLoadSubsetResult, + }) + } else if (options.includeInitialState === false) { + // When explicitly set to false (not just undefined), mark all state as "seen" + // so that all future changes (including deletes) pass through unfiltered. + subscription.markAllStateAsSeen() + } + + // Add to batched listeners + this.changeSubscriptions.add(subscription) + } catch (error) { + if (subscription) { + try { + subscription.unsubscribe() + } catch { + // Preserve the setup error. Cleanup still releases subscriber + // ownership and attempts every subset unload before it throws. + } + } else { + this.removeSubscriber() + } + throw error + } return subscription } @@ -283,12 +299,20 @@ export class CollectionChangesManager< this.activeSubscribersCount++ this.lifecycle.cancelGCTimer() - // Start sync if collection was cleaned up - if ( - this.lifecycle.status === `cleaned-up` || - this.lifecycle.status === `idle` - ) { - this.sync.startSync() + try { + // Start sync if collection was cleaned up + if ( + this.lifecycle.status === `cleaned-up` || + this.lifecycle.status === `idle` + ) { + this.sync.startSync() + } + } catch (error) { + this.activeSubscribersCount = previousSubscriberCount + if (this.activeSubscribersCount === 0) { + this.lifecycle.startGCTimer() + } + throw error } this.events.emitSubscribersChange( diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 87f3bbc51..281e206c7 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -4,6 +4,7 @@ import { PropRef, Value } from '../query/ir.js' import { EventEmitter } from '../event-emitter.js' import { compileExpression } from '../query/compiler/evaluators.js' import { buildCursor } from '../utils/cursor.js' +import { deepEquals } from '../utils.js' import { createFilterFunctionFromExpression, createFilteredCallback, @@ -15,6 +16,7 @@ import type { LoadSubsetOptions, Subscription, SubscriptionEvents, + SubscriptionLoadSubsetErrorEvent, SubscriptionStatus, SubscriptionUnsubscribedEvent, } from '../types.js' @@ -54,6 +56,40 @@ type CollectionSubscriptionOptions = { whereExpression?: BasicExpression /** Callback to call when the subscription is unsubscribed */ onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void + /** Callback for subset-load failures scoped to this subscription. */ + onLoadSubsetError?: (event: SubscriptionLoadSubsetErrorEvent) => void +} + +type TruncatePublicationState = { + loadedInitialState: boolean + snapshotSent: boolean + sentKeys: Set + publishedRows: Map + limitedSnapshotRowCount: number + lastSentKey: string | number | undefined +} + +type SubsetAcquisition = { + options: LoadSubsetOptions + abortController?: AbortController + removeRequestAbortListener?: () => void +} + +type SubsetDemand = SubsetAcquisition & { + requestOptions: LoadSubsetOptions +} + +type TruncateReplayAttempt = { + pending: Set<{ promise: Promise }> + failed: boolean + setupComplete: boolean +} + +type TruncateReplaySession = { + publicationState: TruncatePublicationState + buffer: Array>> + attempts: Set + currentAttempt: TruncateReplayAttempt } export class CollectionSubscription @@ -75,7 +111,7 @@ export class CollectionSubscription * Track all loadSubset calls made by this subscription so we can unload them on cleanup. * We store the exact LoadSubsetOptions we passed to loadSubset to ensure symmetric unload. */ - private loadedSubsets: Array = [] + private subsetDemands: Array = [] private readonly requestedSubsetWhere = new WeakMap< LoadSubsetOptions, BasicExpression @@ -83,6 +119,8 @@ export class CollectionSubscription // Keep track of the keys we've sent (needed for join and orderBy optimizations) private sentKeys = new Set() + private publishedRows = new Map() + private stalePublishedRows = new Map() // Track the count of rows sent via requestLimitedSnapshot for offset-based pagination private limitedSnapshotRowCount = 0 @@ -96,22 +134,24 @@ export class CollectionSubscription // Status tracking private _status: SubscriptionStatus = `ready` + private _lastError: unknown | undefined private pendingLoadSubsetPromises: Set> = new Set() // Cleanup function for truncate event listener private truncateCleanup: (() => void) | undefined - // Truncate buffering state - // When a truncate occurs, we buffer changes until all loadSubset refetches complete - // This prevents a flash of missing content between deletes and new inserts - private isBufferingForTruncate = false - private truncateBuffer: Array>> = [] - private pendingTruncateRefetches: Set> = new Set() + // One replay session owns the publication baseline, overlapping attempts, + // and buffered changes until every attempt settles. + private truncateReplaySession: TruncateReplaySession | undefined public get status(): SubscriptionStatus { return this._status } + public get lastError(): unknown | undefined { + return this._lastError + } + constructor( private collection: CollectionImpl, private callback: (changes: Array>) => void, @@ -119,7 +159,10 @@ export class CollectionSubscription ) { super() if (options.onUnsubscribe) { - this.on(`unsubscribed`, (event) => options.onUnsubscribe!(event)) + this.on(`unsubscribed`, options.onUnsubscribe) + } + if (options.onLoadSubsetError) { + this.on(`loadSubset:error`, options.onLoadSubsetError) } // Auto-index for where expressions if enabled @@ -130,8 +173,9 @@ export class CollectionSubscription const callbackWithSentKeysTracking = ( changes: Array>, ) => { - callback(changes) + this.trackPublishedRows(changes) this.trackSentKeys(changes) + callback(changes) } this.callback = callbackWithSentKeysTracking @@ -157,11 +201,12 @@ export class CollectionSubscription * 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. + * and inserts from refetch) until all loadSubset calls succeed, then emit them together. + * A failed replay keeps the last published snapshot, resumes ordinary deltas, + * and retains subset ownership so a later truncate can retry the replay. */ private handleTruncate() { - // Copy the loaded subsets before clearing (we'll re-request them) - const subsetsToReload = [...this.loadedSubsets] + const demandsToReload = [...this.subsetDemands] // Only buffer if there's an actual loadSubset handler that can do async work. // Without a loadSubset handler, there's nothing to re-request and no reason to buffer. @@ -169,95 +214,258 @@ export class CollectionSubscription const hasLoadSubsetHandler = this.collection._sync.syncLoadSubsetFn !== null // If there are no subsets to reload OR no loadSubset handler, just reset state - if (subsetsToReload.length === 0 || !hasLoadSubsetHandler) { + if (demandsToReload.length === 0 || !hasLoadSubsetHandler) { this.snapshotSent = false this.loadedInitialState = false this.limitedSnapshotRowCount = 0 this.lastSentKey = undefined - this.loadedSubsets = [] return } - // Start buffering BEFORE we receive the delete events from the truncate commit - // This ensures we capture both the deletes and subsequent inserts - this.isBufferingForTruncate = true - this.truncateBuffer = [] - this.pendingTruncateRefetches.clear() + const attempt: TruncateReplayAttempt = { + pending: new Set(), + failed: false, + setupComplete: false, + } + let session = this.truncateReplaySession + if (!session) { + session = { + publicationState: { + loadedInitialState: this.loadedInitialState, + snapshotSent: this.snapshotSent, + sentKeys: new Set(this.sentKeys), + publishedRows: new Map(this.publishedRows), + limitedSnapshotRowCount: this.limitedSnapshotRowCount, + lastSentKey: this.lastSentKey, + }, + buffer: [], + attempts: new Set(), + currentAttempt: attempt, + } + this.truncateReplaySession = session + } + session.attempts.add(attempt) + session.currentAttempt = attempt + + // A newer replay replaces every prior acquisition for these demands. Abort + // the old work before it can install rows into the new generation. + for (const demand of demandsToReload) { + demand.abortController?.abort() + } - // Reset snapshot/pagination tracking state - // Note: We don't need to populate sentKeys here because filterAndFlipChanges - // will skip the delete filter when isBufferingForTruncate is true + // Start buffering before the truncate commit publishes its deletes. Every + // overlapping attempt shares this one publication baseline and buffer. + // Retained rows from an earlier failed replay stay marked until this + // attempt either replaces them or proves they are absent. + + // Reset snapshot/pagination tracking state for the replacement snapshot. this.snapshotSent = false this.loadedInitialState = false this.limitedSnapshotRowCount = 0 this.lastSentKey = undefined - // Clear the loadedSubsets array since we're re-requesting fresh - this.loadedSubsets = [] - - // Defer the loadSubset calls to a microtask so the truncate commit's delete events - // are buffered BEFORE the loadSubset calls potentially trigger nested commits. - // This ensures correct event ordering: deletes first, then inserts. + // Defer the requests so the truncate commit's deletes enter the session + // buffer before a synchronous adapter can publish replacement rows. queueMicrotask(() => { - // Check if we were unsubscribed while waiting - if (!this.isBufferingForTruncate) { - return - } - - // Re-request all previously loaded subsets and track their promises - for (const options of subsetsToReload) { - const syncResult = this.collection._sync.loadSubset(options) + if (this.truncateReplaySession !== session) return + + for (const demand of demandsToReload) { + if (!this.subsetDemands.includes(demand)) continue + + const isCurrentAttempt = () => + this.truncateReplaySession === session && + session.currentAttempt === attempt + const nextAcquisition = this.createSubsetAcquisition(demand) + let syncResult: Promise | true + try { + syncResult = this.loadSubset( + nextAcquisition.options, + isCurrentAttempt, + ) + } catch { + nextAcquisition.abortController.abort() + nextAcquisition.removeRequestAbortListener?.() + attempt.failed = true + continue + } - // Track this loadSubset call so we can unload it later - this.loadedSubsets.push(options) - this.trackLoadSubsetPromise(syncResult) + this.observeLoadSubsetResult( + syncResult, + nextAcquisition.options, + true, + () => isCurrentAttempt() && !nextAcquisition.options.signal?.aborted, + ) - // Track the promise for buffer flushing if (syncResult instanceof Promise) { - this.pendingTruncateRefetches.add(syncResult) - syncResult - .catch(() => { - // Ignore errors - we still want to flush the buffer even if some requests fail - }) - .finally(() => { - this.pendingTruncateRefetches.delete(syncResult) - this.checkTruncateRefetchComplete() - }) + // A transport promise may be shared by several deduplicated logical + // demands. Track each demand separately so one settlement observer + // cannot complete the attempt before the others apply their result. + const pending = { promise: syncResult } + attempt.pending.add(pending) + void syncResult.then( + () => this.settleTruncateReplay(session, attempt, pending), + () => { + // A released demand no longer participates in the current + // replacement. Its cooperative AbortError must not discard the + // successful rows from demands that are still active. + if ( + this.subsetDemands.includes(demand) && + !nextAcquisition.options.signal?.aborted + ) { + attempt.failed = true + } + this.settleTruncateReplay(session, attempt, pending) + }, + ) } - } - // If all loadSubset calls were synchronous (returned true), flush now - // At this point, delete events have already been buffered from the truncate commit - if (this.pendingTruncateRefetches.size === 0) { - this.flushTruncateBuffer() + try { + this.replaceSubsetAcquisition(demand, nextAcquisition) + } catch (error) { + // The old lease is still owned because its release failed. Abort and + // release the new acquisition, but keep observing its work so rows + // from a non-cooperative adapter cannot escape the replay buffer. + nextAcquisition.abortController.abort() + nextAcquisition.removeRequestAbortListener?.() + try { + this.collection._sync.unloadSubset(nextAcquisition.options) + } catch { + // Preserve the first ownership error. The demand still retains the + // old acquisition so normal cleanup can retry that release. + } + this.recordLoadSubsetError(demand.options, error, true) + attempt.failed = true + } } + + attempt.setupComplete = true + this.checkTruncateReplayComplete(session) }) } - /** - * Check if all truncate refetch promises have completed and flush buffer if so - */ - private checkTruncateRefetchComplete() { - if ( - this.pendingTruncateRefetches.size === 0 && - this.isBufferingForTruncate - ) { - this.flushTruncateBuffer() + private settleTruncateReplay( + session: TruncateReplaySession, + attempt: TruncateReplayAttempt, + pending: { promise: Promise }, + ): void { + if (this.truncateReplaySession !== session) return + attempt.pending.delete(pending) + this.checkTruncateReplayComplete(session) + } + + /** Publish only after every overlapping replay attempt has settled. */ + private checkTruncateReplayComplete(session: TruncateReplaySession): void { + if (this.truncateReplaySession !== session) return + for (const attempt of session.attempts) { + if (!attempt.setupComplete || attempt.pending.size > 0) return + } + + if (session.currentAttempt.failed) { + this.abandonTruncateReplay(session) + } else { + this.flushTruncateReplay(session) } } /** - * Flush the truncate buffer, emitting all buffered changes to the callback + * Discard an incomplete current replay and restore the last publication. + * Rows in that publication remain stale until a later source delta or replay + * reconciles them with the source collection. */ - private flushTruncateBuffer() { - this.isBufferingForTruncate = false + private abandonTruncateReplay(session: TruncateReplaySession): void { + if (this.truncateReplaySession !== session) return + const publicationState = session.publicationState + this.loadedInitialState = publicationState.loadedInitialState + this.snapshotSent = publicationState.snapshotSent + this.sentKeys = new Set(publicationState.sentKeys) + this.publishedRows = new Map(publicationState.publishedRows) + this.stalePublishedRows = new Map(publicationState.publishedRows) + this.limitedSnapshotRowCount = publicationState.limitedSnapshotRowCount + this.lastSentKey = publicationState.lastSentKey + this.truncateReplaySession = undefined + } + + /** Publish the complete buffered replacement as one subscriber batch. */ + private flushTruncateReplay(session: TruncateReplaySession): void { + if (this.truncateReplaySession !== session) return + this.truncateReplaySession = undefined + + const retainedDeletes = [...this.stalePublishedRows].map( + ([key, value]): ChangeMessage => ({ + type: `delete`, + key, + value, + }), + ) + this.stalePublishedRows.clear() + + const merged = [...session.buffer.flat(), ...retainedDeletes] + const activeDemandFilters = this.subsetDemands.map((demand) => + demand.requestOptions.where + ? createFilterFunctionFromExpression(demand.requestOptions.where) + : undefined, + ) + const replacement = this.createPublicationDiff( + session.publicationState.publishedRows, + merged, + (value) => activeDemandFilters.some((filter) => filter?.(value) ?? true), + ) + if (replacement.length > 0) this.filteredCallback(replacement) + // Buffering records every source key before active-demand filtering. Reset + // the dedupe set to what the subscriber actually received so a later + // request can publish a row that belonged only to a released demand. + this.sentKeys = new Set(this.publishedRows.keys()) + if (this.orderByIndex) { + this.limitedSnapshotRowCount = this.sentKeys.size + const orderedSentKeys = this.orderByIndex.takeFromStart( + this.sentKeys.size, + (key) => this.sentKeys.has(key), + ) + this.lastSentKey = orderedSentKeys.at(-1) + } + } - // Flatten all buffered changes into a single array for atomic emission - // This ensures consumers see all truncate changes (deletes + inserts) in one callback - const merged = this.truncateBuffer.flat() - if (merged.length > 0) this.filteredCallback(merged) + /** Reduce a replay's raw delete/insert stream to one exact semantic delta. */ + private createPublicationDiff( + baseline: ReadonlyMap, + changes: ReadonlyArray>, + isCoveredByActiveDemand: (value: object) => boolean, + ): Array> { + const finalRows = new Map(baseline) + for (const change of changes) { + if (change.type === `delete`) finalRows.delete(change.key) + else finalRows.set(change.key, change.value) + } + for (const [key, value] of finalRows) { + if (!isCoveredByActiveDemand(value)) finalRows.delete(key) + } - this.truncateBuffer = [] + const replacement: Array> = [] + for (const [key, previousValue] of baseline) { + const value = finalRows.get(key) + if (value === undefined) { + replacement.push({ + type: `delete`, + key, + value: previousValue, + }) + } else if (!deepEquals(value, previousValue)) { + replacement.push({ + type: `update`, + key, + value, + previousValue, + }) + } + } + for (const [key, value] of finalRows) { + if (!baseline.has(key)) replacement.push({ type: `insert`, key, value }) + } + return replacement + } + + private get isBufferingForTruncate(): boolean { + return this.truncateReplaySession !== undefined } setOrderByIndex(index: IndexInterface) { @@ -300,25 +508,140 @@ export class CollectionSubscription } as SubscriptionEvents[typeof eventKey]) } - /** - * Track a loadSubset promise and manage loading status - */ - private trackLoadSubsetPromise(syncResult: Promise | true) { - // Track the promise if it's actually a promise (async work) - if (syncResult instanceof Promise) { + /** Observe an asynchronous subset load and restore status on settlement. */ + private observeLoadSubsetResult( + syncResult: Promise | true, + options: LoadSubsetOptions, + trackStatus: boolean, + shouldReportError: () => boolean = () => true, + ) { + if (!(syncResult instanceof Promise)) return + + if (trackStatus) { this.pendingLoadSubsetPromises.add(syncResult) this.setStatus(`loadingSubset`) + } - const finish = () => { + const finish = () => { + if (trackStatus) { this.pendingLoadSubsetPromises.delete(syncResult) if (this.pendingLoadSubsetPromises.size === 0) { this.setStatus(`ready`) } } - void syncResult.then(finish, finish) + } + + void syncResult.then(finish, (error: unknown) => { + if (shouldReportError()) this.recordLoadSubsetError(options, error) + finish() + }) + } + + private loadSubset( + options: LoadSubsetOptions, + shouldReportError: () => boolean = () => true, + ): Promise | true { + try { + return this.collection._sync.loadSubset(options) + } catch (error) { + if (shouldReportError()) this.recordLoadSubsetError(options, error) + throw error + } + } + + /** Create a fresh, abortable adapter acquisition for a replay generation. */ + private createSubsetAcquisition( + demand: SubsetDemand, + ): SubsetAcquisition & { abortController: AbortController } { + const abortController = new AbortController() + const requestSignal = demand.requestOptions.signal + let removeRequestAbortListener: (() => void) | undefined + + if (requestSignal?.aborted) { + abortController.abort(requestSignal.reason) + } else if (requestSignal) { + const abort = () => abortController.abort(requestSignal.reason) + requestSignal.addEventListener(`abort`, abort, { once: true }) + removeRequestAbortListener = () => + requestSignal.removeEventListener(`abort`, abort) + } + + return { + options: { + ...demand.requestOptions, + signal: abortController.signal, + }, + abortController, + removeRequestAbortListener, + } + } + + /** Replace the adapter lease held for one logical subset demand. */ + private replaceSubsetAcquisition( + demand: SubsetDemand, + next: SubsetAcquisition & { abortController: AbortController }, + ): void { + const previousOptions = demand.options + const removePreviousAbortListener = demand.removeRequestAbortListener + this.collection._sync.unloadSubset(previousOptions) + removePreviousAbortListener?.() + demand.options = next.options + demand.abortController = next.abortController + demand.removeRequestAbortListener = next.removeRequestAbortListener + } + + /** Abort and release one current adapter acquisition. */ + private releaseSubsetDemand(demand: SubsetDemand): void { + demand.abortController?.abort() + try { + this.collection._sync.unloadSubset(demand.options) + } finally { + demand.removeRequestAbortListener?.() } } + /** Start and retain the first acquisition for one logical subset demand. */ + private startSubsetDemand(requestOptions: LoadSubsetOptions): { + demand: SubsetDemand + result: Promise | true + } { + const demand: SubsetDemand = { + requestOptions, + options: requestOptions, + } + const acquisition = this.createSubsetAcquisition(demand) + try { + const result = this.loadSubset(acquisition.options) + demand.options = acquisition.options + demand.abortController = acquisition.abortController + demand.removeRequestAbortListener = acquisition.removeRequestAbortListener + this.subsetDemands.push(demand) + return { demand, result } + } catch (error) { + acquisition.abortController.abort() + acquisition.removeRequestAbortListener?.() + throw error + } + } + + private recordLoadSubsetError( + options: LoadSubsetOptions, + error: unknown, + reportAborted = false, + ): void { + // Aborted subset requests are obsolete demand, not load failures. The + // request may reject after its route has already been released. + if (options.signal?.aborted && !reportAborted) return + + this._lastError = error + this.emitInner(`loadSubset:error`, { + type: `loadSubset:error`, + subscription: this, + options, + error, + }) + } + hasLoadedInitialState() { return this.loadedInitialState } @@ -330,11 +653,15 @@ export class CollectionSubscription emitEvents(changes: Array>): boolean { const newChanges = this.filterAndFlipChanges(changes) + // Reconciliation can reduce a source delta to no visible change. Do not + // wake subscribers for an empty semantic batch. + if (changes.length > 0 && newChanges.length === 0) return false + if (this.isBufferingForTruncate) { // Buffer the changes instead of emitting immediately // This prevents a flash of missing content during truncate/refetch if (newChanges.length > 0) { - this.truncateBuffer.push(newChanges) + this.truncateReplaySession!.buffer.push(newChanges) } return false } else { @@ -387,19 +714,18 @@ export class CollectionSubscription orderBy: opts?.orderBy, limit: opts?.limit, } - const syncResult = this.collection._sync.loadSubset(loadOptions) + + const { demand, result: syncResult } = this.startSubsetDemand(loadOptions) + if (opts?.where) this.requestedSubsetWhere.set(loadOptions, opts.where) // Pass the raw loadSubset result to the caller for external tracking opts?.onLoadSubsetResult?.(syncResult) - // Track this loadSubset call so we can unload it later - this.loadedSubsets.push(loadOptions) - if (opts?.where) this.requestedSubsetWhere.set(loadOptions, opts.where) - - const trackLoadSubsetPromise = opts?.trackLoadSubsetPromise ?? true - if (trackLoadSubsetPromise) { - this.trackLoadSubsetPromise(syncResult) - } + this.observeLoadSubsetResult( + syncResult, + demand.options, + opts?.trackLoadSubsetPromise ?? true, + ) // Also load data immediately from the collection let snapshot: Array> | void @@ -443,15 +769,15 @@ export class CollectionSubscription /** Release one exact subset request while keeping the subscription alive. */ releaseSnapshot(where: BasicExpression): void { - const index = this.loadedSubsets.findIndex( - (options) => - options.where === where || - this.requestedSubsetWhere.get(options) === where, + const index = this.subsetDemands.findIndex( + (demand) => + demand.requestOptions.where === where || + this.requestedSubsetWhere.get(demand.requestOptions) === where, ) if (index === -1) return - const [options] = this.loadedSubsets.splice(index, 1) - if (options) this.collection._sync.unloadSubset(options) + const [demand] = this.subsetDemands.splice(index, 1) + if (demand) this.releaseSubsetDemand(demand) } /** @@ -597,7 +923,10 @@ export class CollectionSubscription this.callback(changes) // Update the row count and last key after sending (for next call's offset/cursor) - this.limitedSnapshotRowCount += changes.length + this.limitedSnapshotRowCount = Math.max( + this.limitedSnapshotRowCount, + currentOffset + changes.length, + ) if (changes.length > 0) { this.lastSentKey = changes[changes.length - 1]!.key } @@ -654,16 +983,16 @@ export class CollectionSubscription offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset subscription: this, } - const syncResult = this.collection._sync.loadSubset(loadOptions) + + const { demand, result: syncResult } = this.startSubsetDemand(loadOptions) // Pass the raw loadSubset result to the caller for external tracking onLoadSubsetResult?.(syncResult) - - // Track this loadSubset call - this.loadedSubsets.push(loadOptions) - if (shouldTrackLoadSubsetPromise) { - this.trackLoadSubsetPromise(syncResult) - } + this.observeLoadSubsetResult( + syncResult, + demand.options, + shouldTrackLoadSubsetPromise, + ) } // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function @@ -676,6 +1005,8 @@ export class CollectionSubscription * Duplicate inserts are filtered out to prevent D2 multiplicity > 1. */ private filterAndFlipChanges(changes: Array>) { + changes = this.reconcileStalePublishedChanges(changes) + if (this.loadedInitialState || this.skipFiltering) { // We loaded the entire initial state or filtering is explicitly skipped // so no need to filter or flip changes @@ -698,14 +1029,16 @@ export class CollectionSubscription if (!keyInSentKeys) { if (change.type === `update`) { newChange = { ...change, type: `insert`, previousValue: undefined } + this.sentKeys.add(change.key) } else if (change.type === `delete`) { // Filter out deletes for keys that have not been sent, // UNLESS we're buffering for truncate (where all deletes should pass through) if (!skipDeleteFilter) { continue } + } else { + this.sentKeys.add(change.key) } - this.sentKeys.add(change.key) } else { // Key was already sent - handle based on change type if (change.type === `insert`) { @@ -725,6 +1058,55 @@ export class CollectionSubscription return newChanges } + /** + * After a failed replay, the source collection is empty but subscribers still + * hold the last good publication. Reconcile the first later source delta for + * each retained key against that publication instead of treating it as a + * duplicate insert. + */ + private reconcileStalePublishedChanges( + changes: Array>, + ): Array> { + if (this.stalePublishedRows.size === 0) return changes + + const reconciled: Array> = [] + for (const change of changes) { + const previous = this.stalePublishedRows.get(change.key) + if (previous === undefined) { + reconciled.push(change) + continue + } + + this.stalePublishedRows.delete(change.key) + if (change.type === `delete`) { + reconciled.push({ + ...change, + value: previous, + previousValue: undefined, + }) + } else if (!deepEquals(previous, change.value)) { + reconciled.push({ + ...change, + type: `update`, + previousValue: previous, + }) + } + } + return reconciled + } + + private trackPublishedRows( + changes: Array>, + ): void { + for (const change of changes) { + if (change.type === `delete`) { + this.publishedRows.delete(change.key) + } else { + this.publishedRows.set(change.key, change.value) + } + } + } + private trackSentKeys(changes: Array>) { if (this.loadedInitialState || this.skipFiltering) { // No need to track sent keys if we loaded the entire state or filtering is skipped. @@ -761,27 +1143,42 @@ export class CollectionSubscription } unsubscribe() { + let firstCleanupError: unknown + // Clean up truncate event listener - this.truncateCleanup?.() + try { + this.truncateCleanup?.() + } catch (error) { + firstCleanupError = error + } this.truncateCleanup = undefined - // Clean up truncate buffer state - this.isBufferingForTruncate = false - this.truncateBuffer = [] - this.pendingTruncateRefetches.clear() + // Stop any buffered replay from publishing after unsubscription. + this.truncateReplaySession = undefined + this.stalePublishedRows.clear() - // Unload all subsets that this subscription loaded - // We pass the exact same LoadSubsetOptions we used for loadSubset - for (const options of this.loadedSubsets) { - this.collection._sync.unloadSubset(options) + // Release the current adapter acquisition for each logical subset demand. + for (const demand of this.subsetDemands) { + try { + this.releaseSubsetDemand(demand) + } catch (error) { + firstCleanupError ??= error + } } - this.loadedSubsets = [] + this.subsetDemands = [] - this.emitInner(`unsubscribed`, { - type: `unsubscribed`, - subscription: this, - }) - // Clear all event listeners to prevent memory leaks - this.clearListeners() + try { + this.emitInner(`unsubscribed`, { + type: `unsubscribed`, + subscription: this, + }) + } catch (error) { + firstCleanupError ??= error + } finally { + // Clear all event listeners to prevent memory leaks + this.clearListeners() + } + + if (firstCleanupError !== undefined) throw firstCleanupError } } diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index ce8f5729c..5a8f49f8e 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -33,6 +33,15 @@ type DeferredLoadSubset = { deferred: Deferred } +type LoadSubsetOperation = { + pending: Set> + waiting: boolean + completed: boolean + hasError: boolean + error?: unknown + deferred?: Deferred +} + export class CollectionSyncManager< TOutput extends object = Record, TKey extends string | number = string | number, @@ -56,10 +65,13 @@ export class CollectionSyncManager< null private pendingLoadSubsetPromises: Set> = new Set() + private activeLoadSubsetOperation: LoadSubsetOperation | undefined + private loadSubsetOperations = new Set() private syncStartDeferred = false private syncStartRequested = false private deferredLoadSubsets: Array = [] private syncEpoch = 0 + private loadSubsetSession = 0 /** * Creates a new CollectionSyncManager instance @@ -579,6 +591,97 @@ export class CollectionSyncManager< return this.waitForPendingLoadSubset() } + /** @internal Observe subset requests caused by one imperative operation. */ + public beginLoadSubsetOperation(): { + wait: () => true | Promise + cancel: () => void + } { + const operation: LoadSubsetOperation = { + pending: new Set(), + waiting: false, + completed: false, + hasError: false, + } + // A new imperative operation owns future requests. Older operations keep + // waiting for the promises they already acquired, but cannot absorb work + // caused by a superseding physical window. + this.activeLoadSubsetOperation = operation + this.loadSubsetOperations.add(operation) + return { + wait: () => this.waitForLoadSubsetOperation(operation), + cancel: () => { + operation.completed = true + this.loadSubsetOperations.delete(operation) + if (this.activeLoadSubsetOperation === operation) { + this.activeLoadSubsetOperation = undefined + } + }, + } + } + + private waitForLoadSubsetOperation( + operation: LoadSubsetOperation, + ): true | Promise { + operation.waiting = true + if (operation.pending.size === 0) { + operation.completed = true + this.loadSubsetOperations.delete(operation) + if (this.activeLoadSubsetOperation === operation) { + this.activeLoadSubsetOperation = undefined + } + return operation.hasError ? Promise.reject(operation.error) : true + } + operation.deferred = createDeferred() + return operation.deferred.promise + } + + private settleLoadSubsetOperation( + operation: LoadSubsetOperation, + promise: Promise, + outcome: { ok: true } | { ok: false; error: unknown }, + ): void { + if (operation.completed) return + operation.pending.delete(promise) + if (!outcome.ok && !operation.hasError) { + operation.hasError = true + operation.error = outcome.error + } + if (!operation.waiting || operation.pending.size > 0) return + + // A resolved request can synchronously publish source rows that register + // follow-up loads. Let those registrations join this operation before it + // is considered complete. + queueMicrotask(() => { + if (operation.completed || operation.pending.size > 0) return + operation.completed = true + this.loadSubsetOperations.delete(operation) + if (this.activeLoadSubsetOperation === operation) { + this.activeLoadSubsetOperation = undefined + } + if (operation.hasError) { + operation.deferred!.reject(operation.error) + } else { + operation.deferred!.resolve() + } + }) + } + + /** @internal Attach a relevant existing request to the active operation. */ + public trackLoadSubsetOperationPromise(promise: Promise): void { + const operation = this.activeLoadSubsetOperation + if (!operation || operation.pending.has(promise)) return + + operation.pending.add(promise) + void promise.then( + () => this.settleLoadSubsetOperation(operation, promise, { ok: true }), + (error) => + this.settleLoadSubsetOperation(operation, promise, { + ok: false, + error, + }), + ) + } + private async waitForPendingLoadSubset(): Promise { do { await Promise.all([...this.pendingLoadSubsetPromises]) @@ -590,8 +693,10 @@ export class CollectionSyncManager< * @internal This is for internal coordination (e.g., live-query glue code), not for general use. */ public trackLoadPromise(promise: Promise): void { + const loadSubsetSession = this.loadSubsetSession const loadingStarting = !this.isLoadingSubset this.pendingLoadSubsetPromises.add(promise) + this.trackLoadSubsetOperationPromise(promise) if (loadingStarting) { this._events.emit(`loadingSubset:change`, { @@ -604,6 +709,8 @@ export class CollectionSyncManager< } const finish = () => { + if (loadSubsetSession !== this.loadSubsetSession) return + const loadingEnding = this.pendingLoadSubsetPromises.size === 1 && this.pendingLoadSubsetPromises.has(promise) @@ -684,6 +791,7 @@ export class CollectionSyncManager< // Invalidate callbacks retained by asynchronous work from this session // before invoking adapter cleanup or allowing a new session to start. this.syncEpoch++ + this.loadSubsetSession++ try { if (this.syncCleanupFn) { this.syncCleanupFn() @@ -708,6 +816,26 @@ export class CollectionSyncManager< this.syncUnloadSubsetFn = null this.syncStartDeferred = false this.syncStartRequested = false + const wasLoadingSubset = this.pendingLoadSubsetPromises.size > 0 + this.pendingLoadSubsetPromises.clear() + if (wasLoadingSubset) { + this._events.emit(`loadingSubset:change`, { + type: `loadingSubset:change`, + collection: this.collection, + isLoadingSubset: false, + previousIsLoadingSubset: true, + loadingSubsetTransition: `end`, + }) + } + this.activeLoadSubsetOperation = undefined + for (const operation of this.loadSubsetOperations) { + if (!operation.completed) { + operation.completed = true + operation.pending.clear() + operation.deferred?.resolve() + } + } + this.loadSubsetOperations.clear() const deferredLoadSubsets = this.deferredLoadSubsets this.deferredLoadSubsets = [] for (const { deferred } of deferredLoadSubsets) { diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 9991c7794..9e77bbc9e 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -250,20 +250,31 @@ export function createEffect< // The dispose function is referenced by both the returned Effect object // and the onSourceError callback, so we define it first. - const dispose = async () => { - if (disposed) return + let disposalPromise: Promise | undefined + const dispose = (): Promise => { + if (disposalPromise) return disposalPromise disposed = true // Abort signal for in-flight handlers abortController.abort() - // Tear down the pipeline (unsubscribe from sources, etc.) - runner.dispose() + disposalPromise = (async () => { + // Tear down the pipeline (unsubscribe from sources, etc.) + let cleanupError: unknown + try { + runner.dispose() + } catch (error) { + cleanupError = error + } - // Wait for any in-flight async handlers to settle - if (inFlightHandlers.size > 0) { - await Promise.allSettled([...inFlightHandlers]) - } + // Wait for any in-flight async handlers to settle + if (inFlightHandlers.size > 0) { + await Promise.allSettled([...inFlightHandlers]) + } + + if (cleanupError !== undefined) throw cleanupError + })() + return disposalPromise } // Create and start the pipeline @@ -288,10 +299,27 @@ export function createEffect< } // Auto-dispose — the effect can no longer function - dispose() + void dispose().catch((cleanupError) => { + console.error( + `[Effect '${id}'] failed to dispose after a source error:`, + cleanupError, + ) + }) }, }) - runner.start() + try { + runner.start() + } catch (error) { + try { + runner.dispose() + } catch (cleanupError) { + console.error( + `[Effect '${id}'] failed to dispose after a startup error:`, + cleanupError, + ) + } + throw error + } return { dispose, @@ -380,6 +408,7 @@ class EffectPipelineRunner { // Reentrance guard private isGraphRunning = false + private starting = false private disposed = false // When dispose() is called mid-graph-run, defer heavy cleanup until the run completes private deferredCleanup = false @@ -443,10 +472,16 @@ class EffectPipelineRunner { this.graph.finalize() } + private isDisposed(): boolean { + return this.disposed + } + /** Subscribe to source collections and start processing */ start(): void { + this.starting = true if (this.collectionSources.length === 0) { // Nothing to subscribe to + this.starting = false return } @@ -470,6 +505,11 @@ class EffectPipelineRunner { >() for (const source of this.collectionSources) { + if (this.isDisposed()) { + this.starting = false + return + } + const { sourceId, alias, collection } = source const collectionId = collection.id @@ -525,23 +565,40 @@ class EffectPipelineRunner { } } - // Determine subscription options based on ordered vs unordered path - const subscriptionOptions = this.buildSubscriptionOptions( - alias, - isLazy, - orderByInfo, - whereExpression, - ) - // Subscribe to source changes - const subscription = collection.subscribeChanges( - changeCallback, - subscriptionOptions, - ) + const subscription = collection.subscribeChanges(changeCallback, { + ...this.buildSubscriptionOptions( + alias, + isLazy, + orderByInfo, + whereExpression, + ), + onLoadSubsetError: ({ error }) => { + this.onSourceError(normaliseError(error)) + }, + }) // Store subscription immediately so the join compiler can find it this.subscriptions[sourceId] = subscription + const unsubscribe = () => { + subscription.unsubscribe() + delete this.subscriptions[sourceId] + } + + // subscribeChanges can synchronously report a source error and dispose + // the runner before returning the subscription. + if (this.isDisposed()) { + unsubscribe() + this.starting = false + return + } + + // Own the subscription before any ordered snapshot or lazy demand can + // throw. A partially started effect has no handle for its caller to + // dispose, so start() must be able to release every acquired source. + this.unsubscribeCallbacks.add(unsubscribe) + const lazyCallbacks = this.lazySourcesCallbacks[sourceId] if (lazyCallbacks) { lazyCallbacks.setDemand = (plan: LazyDemandPlan, keys: Set) => @@ -559,11 +616,6 @@ class EffectPipelineRunner { this.requestInitialOrderedSnapshot(alias, orderByInfo, subscription) } - this.unsubscribeCallbacks.add(() => { - subscription.unsubscribe() - delete this.subscriptions[sourceId] - }) - // Listen for status changes on source collections const statusUnsubscribe = collection.on(`status:change`, (event) => { if (this.disposed) return @@ -643,6 +695,7 @@ class EffectPipelineRunner { this.initialLoadComplete = true } } + this.starting = false } /** Handle incoming changes from a source collection */ @@ -659,13 +712,22 @@ class EffectPipelineRunner { plan: LazyDemandPlan, keys: Set, ): void { - const update = this.demand.setDemand(subscription, plan, keys) + let update + try { + update = this.demand.setDemand(subscription, plan, keys) + } catch (error) { + // The subscription error event already reports adapter failures and + // disposes this effect. Do not let that query-local failure escape the + // source commit, but keep unrelated graph errors visible. + if (subscription.lastError !== error) throw error + if (this.starting) throw error + return + } if (update.ready instanceof Promise) { - void update.ready.catch((error: unknown) => { - this.onSourceError( - error instanceof Error ? error : new Error(String(error)), - ) - }) + // Each segment reports its own failure through the subscription. Consume + // the aggregate rejection so Promise.all does not create a second, + // detached error channel. + void update.ready.then(undefined, () => {}) } } @@ -939,23 +1001,34 @@ class EffectPipelineRunner { this.lastLoadRequestKey.set(sourceId, cursor.loadRequestKey) - subscription.requestLimitedSnapshot({ - orderBy: cursor.normalizedOrderBy, - limit: n, - minValues: cursor.minValues, - trackLoadSubsetPromise: false, - onLoadSubsetResult: (loadResult: Promise | true) => { - // Track in-flight load to prevent redundant concurrent requests - if (loadResult instanceof Promise) { - this.pendingOrderedLoadPromise = loadResult - loadResult.finally(() => { - if (this.pendingOrderedLoadPromise === loadResult) { - this.pendingOrderedLoadPromise = undefined + try { + subscription.requestLimitedSnapshot({ + orderBy: cursor.normalizedOrderBy, + limit: n, + minValues: cursor.minValues, + trackLoadSubsetPromise: false, + onLoadSubsetResult: (loadResult: Promise | true) => { + // Track in-flight load to prevent redundant concurrent requests + if (loadResult instanceof Promise) { + this.pendingOrderedLoadPromise = loadResult + const finish = () => { + if (this.pendingOrderedLoadPromise === loadResult) { + this.pendingOrderedLoadPromise = undefined + } } - }) - } - }, - }) + void loadResult.then(finish, finish) + } + }, + }) + } catch (error) { + if (subscription.lastError !== error) throw error + // subscribeChanges already routed the error through onSourceError. Do + // not let an automatic refill fail the source transaction that exposed + // the missing row. + if (this.lastLoadRequestKey.get(sourceId) === cursor.loadRequestKey) { + this.lastLoadRequestKey.delete(sourceId) + } + } } /** @@ -986,8 +1059,15 @@ class EffectPipelineRunner { this.disposed = true this.subscribedToAllCollections = false - // Immediately unsubscribe from sources and clear cheap state - this.unsubscribeCallbacks.forEach((fn) => fn()) + // Immediately unsubscribe from every source, even if one release fails. + let firstCleanupError: unknown + for (const unsubscribe of this.unsubscribeCallbacks) { + try { + unsubscribe() + } catch (error) { + firstCleanupError ??= error + } + } this.unsubscribeCallbacks.clear() this.sentToD2KeysBySource.clear() this.pendingChanges.clear() @@ -1016,6 +1096,8 @@ class EffectPipelineRunner { } else { this.finalCleanup() } + + if (firstCleanupError !== undefined) throw firstCleanupError } /** Clear graph references — called after graph run completes or immediately from dispose */ @@ -1116,9 +1198,10 @@ function trackPromise( inFlightHandlers: Set>, ): void { inFlightHandlers.add(promise) - promise.finally(() => { + const finish = () => { inFlightHandlers.delete(promise) - }) + } + void promise.then(finish, finish) } /** Report an error to the onError callback or console */ @@ -1127,7 +1210,7 @@ function reportError( event: DeltaEvent, onError?: (error: Error, event: DeltaEvent) => void, ): void { - const normalised = error instanceof Error ? error : new Error(String(error)) + const normalised = normaliseError(error) if (onError) { try { onError(normalised, event) @@ -1140,3 +1223,7 @@ function reportError( console.error(`[Effect] Unhandled error in handler:`, normalised) } } + +function normaliseError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 8649bc0bc..893c8f10a 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -190,9 +190,13 @@ export function createLiveQueryCollection< // been validated by the public signatures, but the branch loses that precision. const options = liveQueryCollectionOptions(config as any) - // Merge custom utils if provided, preserving the getBuilder() method for dependency tracking + // Merge custom utils without evaluating internal getters such as + // lastSubsetError into stale data properties. if (config.utils) { - options.utils = { ...options.utils, ...config.utils } + Object.defineProperties( + options.utils, + Object.getOwnPropertyDescriptors(config.utils), + ) } return bridgeToCreateCollection(options) as CollectionForContext< diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 9e13a6570..be53fed52 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -49,6 +49,8 @@ import type { AllCollectionEvents } from '../../collection/events.js' export type LiveQueryCollectionUtils = UtilsRecord & { getRunCount: () => number + /** Most recent subset-load failure observed by this live query. */ + readonly lastSubsetError: unknown | undefined /** * Sets the offset and limit of an ordered query. * Is a no-op if the query is not ordered. @@ -114,6 +116,7 @@ export class CollectionConfigBuilder< private isInErrorState = false private fatalQueryError = false private readonly erroredSourceIds = new Set() + private lastSubsetError: unknown | undefined // Reference to the live query collection for error state transitions public liveQueryCollection?: Collection @@ -121,6 +124,9 @@ export class CollectionConfigBuilder< private windowFn: ((options: WindowOptions) => void) | undefined private readonly initialWindow: WindowOptions | undefined private currentWindow: WindowOptions | undefined + private activeWindowOperation: + | { failed: boolean; error?: unknown } + | undefined private maybeRunGraphFn: (() => void) | undefined @@ -244,6 +250,7 @@ export class CollectionConfigBuilder< getConfig(): CollectionConfigSingleRowOption & { utils: LiveQueryCollectionUtils } { + const builder = this return { id: this.id, getKey: @@ -262,6 +269,9 @@ export class CollectionConfigBuilder< singleResult: this.query.singleResult, utils: { getRunCount: this.getRunCount.bind(this), + get lastSubsetError() { + return builder.lastSubsetError + }, setWindow: this.setWindow.bind(this), getWindow: this.getWindow.bind(this), [LIVE_QUERY_INTERNAL]: { @@ -279,10 +289,16 @@ export class CollectionConfigBuilder< throw new SetWindowRequiresOrderByError() } + const loadOperation = + this.liveQueryCollection?._sync.beginLoadSubsetOperation() const previousWindow = this.currentWindow ?? this.initialWindow + const previousOperation = this.activeWindowOperation + const operation: { failed: boolean; error?: unknown } = { failed: false } + this.activeWindowOperation = operation try { this.windowFn(options) this.maybeRunGraphFn?.() + if (operation.failed) throw operation.error this.currentWindow = options } catch (error) { if (previousWindow) { @@ -294,10 +310,13 @@ export class CollectionConfigBuilder< // window rather than replacing it with a rollback failure. } } + loadOperation?.cancel() throw error + } finally { + this.activeWindowOperation = previousOperation } - return this.liveQueryCollection?._sync.waitForCurrentLoadSubset() ?? true + return loadOperation?.wait() ?? true } getWindow(): { offset: number; limit: number } | undefined { @@ -356,8 +375,36 @@ export class CollectionConfigBuilder< failDemand(planId: string, generation: number, error: unknown): void { const demand = this.activeDemands.get(planId) if (!demand || demand.generation !== generation) return + this.recordSubsetError(error) + if (this.activeWindowOperation) { + this.activeWindowOperation.failed = true + this.activeWindowOperation.error = error + } const message = error instanceof Error ? error.message : String(error) - this.transitionToError(`Subset demand '${planId}' failed: ${message}`) + this.transitionToError( + `Subset demand '${planId}' failed: ${message}`, + error, + ) + } + + recordSubsetError(error: unknown, fatalBeforeReady = false): void { + this.lastSubsetError = error + if (this.activeWindowOperation) { + this.activeWindowOperation.failed = true + this.activeWindowOperation.error = error + } + if (fatalBeforeReady) { + const message = error instanceof Error ? error.message : String(error) + this.transitionToError(`Initial subset load failed: ${message}`, error) + } + } + + trackSubsetLoadPromise(promise: Promise): void { + this.liveQueryCollection!._sync.trackLoadPromise(promise) + } + + trackSubsetLoadOperationPromise(promise: Promise): void { + this.liveQueryCollection!._sync.trackLoadSubsetOperationPromise(promise) } retireDemand(planId: string): void { @@ -632,6 +679,7 @@ export class CollectionConfigBuilder< this.isInErrorState = false this.fatalQueryError = false this.erroredSourceIds.clear() + this.lastSubsetError = undefined // Store config and syncState as instance properties for the duration of this sync session this.currentSyncConfig = config @@ -641,49 +689,20 @@ export class CollectionConfigBuilder< unsubscribeCallbacks: new Set<() => void>(), } - // Extend the pipeline such that it applies the incoming changes to the collection - const fullSyncState = this.extendPipelineWithChangeProcessing( - config, - syncState, - ) - this.currentSyncState = fullSyncState - - // Listen for scheduler context clears to clean up our pending state - // Re-register on each sync start so the listener is active for the sync session's lifetime - this.unsubscribeFromSchedulerClears = transactionScopedScheduler.onClear( - (contextId) => { - this.clearPendingGraphRun(contextId) - }, - ) + let tornDown = false + const teardown = () => { + if (tornDown) return + tornDown = true - // Listen for loadingSubset changes on the live query collection BEFORE subscribing. - // This ensures we don't miss the event if subset loading completes synchronously. - // When isLoadingSubset becomes false, we may need to mark the collection as ready - // (if all source collections are already ready but we were waiting for subset load to complete) - const loadingSubsetUnsubscribe = config.collection.on( - `loadingSubset:change`, - (event) => { - if (!event.isLoadingSubset) { - // Subset loading finished, check if we can now mark ready - this.updateLiveQueryStatus(config) + let firstCleanupError: unknown + for (const unsubscribe of syncState.unsubscribeCallbacks) { + try { + unsubscribe() + } catch (error) { + firstCleanupError ??= error } - }, - ) - syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) - - const loadSubsetDataCallbacks = this.subscribeToAllCollections( - config, - fullSyncState, - ) - - this.maybeRunGraphFn = () => this.scheduleGraphRun(loadSubsetDataCallbacks) - - // Initial run with callback to load more data if needed - this.scheduleGraphRun(loadSubsetDataCallbacks) - - // Return the unsubscribe function - return () => { - syncState.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe()) + } + syncState.unsubscribeCallbacks.clear() // Clear current sync session state this.currentSyncConfig = undefined @@ -724,7 +743,61 @@ export class CollectionConfigBuilder< // The scheduler's listener Set would otherwise keep a strong reference to this builder this.unsubscribeFromSchedulerClears?.() this.unsubscribeFromSchedulerClears = undefined + + if (firstCleanupError !== undefined) throw firstCleanupError + } + + try { + // Extend the pipeline such that it applies the incoming changes to the collection + const fullSyncState = this.extendPipelineWithChangeProcessing( + config, + syncState, + ) + this.currentSyncState = fullSyncState + + // Listen for scheduler context clears to clean up our pending state + // Re-register on each sync start so the listener is active for the sync session's lifetime + this.unsubscribeFromSchedulerClears = transactionScopedScheduler.onClear( + (contextId) => { + this.clearPendingGraphRun(contextId) + }, + ) + + // Listen for loadingSubset changes on the live query collection BEFORE subscribing. + // This ensures we don't miss the event if subset loading completes synchronously. + // When isLoadingSubset becomes false, we may need to mark the collection as ready + // (if all source collections are already ready but we were waiting for subset load to complete) + const loadingSubsetUnsubscribe = config.collection.on( + `loadingSubset:change`, + (event) => { + if (!event.isLoadingSubset) { + // Subset loading finished, check if we can now mark ready + this.updateLiveQueryStatus(config) + } + }, + ) + syncState.unsubscribeCallbacks.add(loadingSubsetUnsubscribe) + + const loadSubsetDataCallbacks = this.subscribeToAllCollections( + config, + fullSyncState, + ) + + this.maybeRunGraphFn = () => + this.scheduleGraphRun(loadSubsetDataCallbacks) + + // Initial run with callback to load more data if needed + this.scheduleGraphRun(loadSubsetDataCallbacks) + } catch (error) { + try { + teardown() + } catch { + // Preserve the setup failure. It is the error the caller can act on. + } + throw error } + + return teardown } /** @@ -1027,19 +1100,19 @@ export class CollectionConfigBuilder< /** * Transition the live query to error state */ - private transitionToError(message: string) { + private transitionToError(message: string, error?: unknown) { this.fatalQueryError = true - this.setErrorState(message) + this.setErrorState(message, error) } - private setErrorState(message: string) { + private setErrorState(message: string, error?: unknown) { this.isInErrorState = true // Log error to console for debugging console.error(`[Live Query Error] ${message}`) // Transition live query collection to error state - this.liveQueryCollection?._lifecycle.setStatus(`error`) + this.liveQueryCollection?._lifecycle.markError(error ?? new Error(message)) } private allRequiredSourcesReady() { diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 66279737c..24e973662 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -14,6 +14,7 @@ import { SubsetDemandController } from './subset-demand-controller.js' import type { Collection } from '../../collection/index.js' import type { ChangeMessage, + SubscriptionLoadSubsetErrorEvent, SubscriptionStatusChangeEvent, } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' @@ -76,15 +77,33 @@ export class CollectionSubscriber< private subscribeToChanges(whereExpression?: BasicExpression) { const orderByInfo = this.getOrderByInfo() + let initialSubsetPending = !this.collectionConfigBuilder.isLazySource( + this.sourceId, + ) // Direct load promise tracking: pipes loadSubset results straight to the // live query collection, avoiding the multi-hop deferred promise chain that // can break under microtask timing (e.g., queueMicrotask in TanStack Query). const trackLoadResult = (result: Promise | true) => { if (result instanceof Promise) { - this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise( - result, - ) + // Defer the tracked rejection by one microtask so the subscription's + // error event can put an initial live query in error before loading + // state would otherwise let it become ready. + const trackedResult = result.catch(async (error: unknown) => { + await Promise.resolve() + throw error + }) + this.collectionConfigBuilder.trackSubsetLoadPromise(trackedResult) + if (initialSubsetPending) { + void result.then( + () => { + initialSubsetPending = false + }, + () => {}, + ) + } + } else { + initialSubsetPending = false } } @@ -106,6 +125,12 @@ export class CollectionSubscriber< } } } + const onLoadSubsetError = (event: SubscriptionLoadSubsetErrorEvent) => { + this.collectionConfigBuilder.recordSubsetError( + event.error, + initialSubsetPending, + ) + } // Create subscription with onStatusChange - listener is registered before any async work let subscription: CollectionSubscription @@ -115,6 +140,7 @@ export class CollectionSubscriber< orderByInfo, onStatusChange, trackLoadResult, + onLoadSubsetError, ) } else { // Lazy sources load only the subsets demanded by the compiled graph. @@ -126,7 +152,10 @@ export class CollectionSubscriber< whereExpression, includeInitialState, onStatusChange, + trackLoadResult, + onLoadSubsetError, ) + this.registerSubscriptionCleanup(subscription) } // Check current status after subscribing - if status is 'loadingSubset', track it. @@ -138,6 +167,12 @@ export class CollectionSubscriber< this.ensureLoadingPromise(subscription) } + return subscription + } + + private registerSubscriptionCleanup( + subscription: CollectionSubscription, + ): void { const unsubscribe = () => { // If subscription has a pending promise, resolve it before unsubscribing const deferred = this.subscriptionLoadingPromises.get(subscription) @@ -154,7 +189,6 @@ export class CollectionSubscriber< this.collectionConfigBuilder.currentSyncState!.unsubscribeCallbacks.add( unsubscribe, ) - return subscription } setDemand( @@ -162,7 +196,22 @@ export class CollectionSubscriber< plan: LazyDemandPlan, keys: Set, ): void { - const update = this.demand.setDemand(subscription, plan, keys) + let update + try { + update = this.demand.setDemand(subscription, plan, keys) + } catch (error) { + // CollectionSubscription reports adapter failures before rethrowing. + // Convert that synchronous form to the same query-local fatal demand + // state as a rejected load, without letting it escape the source commit. + // Preserve unrelated graph/programming errors as throws. + if (subscription.lastError !== error) throw error + const isInitialSync = + this.collectionConfigBuilder.liveQueryCollection?.status === `loading` + const generation = this.collectionConfigBuilder.beginDemand(plan.id) + this.collectionConfigBuilder.failDemand(plan.id, generation, error) + if (isInitialSync) throw error + return + } if (!update.changed) return if (update.empty) { @@ -172,6 +221,7 @@ export class CollectionSubscriber< 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) => @@ -215,6 +265,8 @@ export class CollectionSubscriber< whereExpression: BasicExpression | undefined, includeInitialState: boolean, onStatusChange: (event: SubscriptionStatusChangeEvent) => void, + onLoadSubsetResult: (result: Promise | true) => void, + onLoadSubsetError: (event: SubscriptionLoadSubsetErrorEvent) => void, ): CollectionSubscription { const sendChanges = ( changes: Array>, @@ -231,23 +283,14 @@ export class CollectionSubscriber< // Track loading via the loadSubset promise directly. // requestSnapshot uses trackLoadSubsetPromise: false (needed for truncate handling), // so we use onLoadSubsetResult to get the promise and track it ourselves. - const onLoadSubsetResult = includeInitialState - ? (result: Promise | true) => { - if (result instanceof Promise) { - this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise( - result, - ) - } - } - : undefined - const subscription = this.collection.subscribeChanges(sendChanges, { ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, + onLoadSubsetError, orderBy: hints.orderBy, limit: hints.limit, - onLoadSubsetResult, + onLoadSubsetResult: includeInitialState ? onLoadSubsetResult : undefined, }) return subscription @@ -258,6 +301,7 @@ export class CollectionSubscriber< orderByInfo: OrderByOptimizationInfo, onStatusChange: (event: SubscriptionStatusChangeEvent) => void, onLoadSubsetResult: (result: Promise | true) => void, + onLoadSubsetError: (event: SubscriptionLoadSubsetErrorEvent) => void, ): CollectionSubscription { const { orderBy, offset, limit, index } = orderByInfo @@ -302,8 +346,10 @@ export class CollectionSubscriber< const subscription = this.collection.subscribeChanges(sendChangesInRange, { whereExpression, onStatusChange, + onLoadSubsetError, }) subscriptionHolder.current = subscription + this.registerSubscriptionCleanup(subscription) // Listen for truncate events to reset cursor tracking state and sentToD2Keys // This ensures that after a must-refetch/truncate, we don't use stale cursor data @@ -371,16 +417,26 @@ export class CollectionSubscriber< return true } - if (this.pendingOrderedLoadPromise) { - // Wait for in-flight ordered loads to resolve before issuing another request. - return true - } - // `dataNeeded` probes the orderBy operator to see if it needs more data // if it needs more data, it returns the number of items it needs const n = dataNeeded() if (n > 0) { - this.loadNextItems(n, subscription) + if (this.pendingOrderedLoadPromise) { + // The current window still needs the in-flight coverage. Attach it to + // this operation without making an unrelated or superseded request a + // dependency of every window change. + this.collectionConfigBuilder.trackSubsetLoadOperationPromise( + this.pendingOrderedLoadPromise, + ) + return true + } + try { + this.loadNextItems(n, subscription) + } catch (error) { + if (subscription.lastError !== error) throw error + // The subscription already reported the failure. Automatic refills + // must not make the source transaction that exposed the gap fail. + } } return true } @@ -430,18 +486,35 @@ export class CollectionSubscriber< ) if (!cursor) return // Duplicate request — skip - this.lastLoadRequestKey = cursor.loadRequestKey + const loadRequestKey = cursor.loadRequestKey + this.lastLoadRequestKey = loadRequestKey // Take the `n` items after the biggest sent value // Omit offset so requestLimitedSnapshot can advance based on // the number of rows already loaded (supports offset-based backends). - subscription.requestLimitedSnapshot({ - orderBy: cursor.normalizedOrderBy, - limit: n, - minValues: cursor.minValues, - trackLoadSubsetPromise: false, - onLoadSubsetResult: this.orderedLoadSubsetResult, - }) + try { + subscription.requestLimitedSnapshot({ + orderBy: cursor.normalizedOrderBy, + limit: n, + minValues: cursor.minValues, + trackLoadSubsetPromise: false, + onLoadSubsetResult: (result) => { + if (result instanceof Promise) { + void result.then(undefined, () => { + if (this.lastLoadRequestKey === loadRequestKey) { + this.lastLoadRequestKey = undefined + } + }) + } + this.orderedLoadSubsetResult?.(result) + }, + }) + } catch (error) { + if (this.lastLoadRequestKey === loadRequestKey) { + this.lastLoadRequestKey = undefined + } + throw error + } } private getWhereClause(): BasicExpression | undefined { @@ -491,8 +564,6 @@ export class CollectionSubscriber< this.subscriptionLoadingPromises.set(subscription, { resolve: resolve!, }) - this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise( - promise, - ) + this.collectionConfigBuilder.trackSubsetLoadPromise(promise) } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 73f1115db..45f0ddd04 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -229,6 +229,14 @@ export interface SubscriptionStatusEvent { status: T } +/** Event emitted when a subset requested by this subscription fails to load. */ +export interface SubscriptionLoadSubsetErrorEvent { + type: `loadSubset:error` + subscription: Subscription + options: LoadSubsetOptions + error: unknown +} + /** * Event emitted when subscription is unsubscribed */ @@ -244,6 +252,7 @@ export type SubscriptionEvents = { 'status:change': SubscriptionStatusChangeEvent 'status:ready': SubscriptionStatusEvent<`ready`> 'status:loadingSubset': SubscriptionStatusEvent<`loadingSubset`> + 'loadSubset:error': SubscriptionLoadSubsetErrorEvent unsubscribed: SubscriptionUnsubscribedEvent } @@ -254,6 +263,8 @@ export type SubscriptionEvents = { export interface Subscription extends EventEmitter { /** Current status of the subscription */ readonly status: SubscriptionStatus + /** Most recent subset-load failure observed by this subscription. */ + readonly lastError: unknown | undefined } /** @@ -319,6 +330,11 @@ export type LoadSubsetOptions = { subscription?: Subscription } +/** + * Loads one subset and transfers its ongoing resource ownership only after + * returning `true` or a promise. An implementation that throws synchronously + * must release any partially acquired resource before throwing. + */ export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise export type UnloadSubsetFn = (options: LoadSubsetOptions) => void @@ -893,6 +909,8 @@ export interface SubscribeChangesOptions< * @internal */ onLoadSubsetResult?: (result: Promise | true) => void + /** Receives subset-load failures scoped to this subscription. @internal */ + onLoadSubsetError?: (event: SubscriptionLoadSubsetErrorEvent) => void } export interface SubscribeChangesSnapshotOptions< diff --git a/packages/db/tests/collection-subscribe-changes.test.ts b/packages/db/tests/collection-subscribe-changes.test.ts index 4f851f08a..810acdcd1 100644 --- a/packages/db/tests/collection-subscribe-changes.test.ts +++ b/packages/db/tests/collection-subscribe-changes.test.ts @@ -2151,6 +2151,74 @@ describe(`Collection.subscribeChanges`, () => { whereExpression: eq(new PropRef([`status`]), `active`), }) }).toThrow(`Cannot specify both 'where' and 'whereExpression' options`) + expect(collection.subscriberCount).toBe(0) + }) + + it(`releases subscriber ownership when a where callback throws`, () => { + const failure = new Error(`where callback failed`) + const collection = createCollection<{ id: number; status: string }>({ + id: `where-callback-error-test`, + getKey: (item) => item.id, + sync: { sync: () => {} }, + }) + + expect(() => + collection.subscribeChanges(() => {}, { + where: () => { + throw failure + }, + }), + ).toThrow(failure) + expect(collection.subscriberCount).toBe(0) + }) + + it(`rolls back subscriber ownership when starting sync throws`, () => { + const failure = new Error(`sync setup failed`) + const collection = createCollection<{ id: number }>({ + id: `subscriber-start-sync-error-test`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: () => { + throw failure + }, + }, + }) + + expect(() => collection.subscribeChanges(() => {})).toThrow(failure) + expect(collection.subscriberCount).toBe(0) + expect(collection.status).toBe(`error`) + }) + + it(`preserves setup failure when subscription cleanup also throws`, () => { + const loadFailure = new Error(`initial subset failed`) + const unloadFailure = new Error(`subset cleanup failed`) + const collection = createCollection<{ id: number }>({ + id: `subscriber-load-and-unload-error-test`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => true, + unloadSubset: () => { + throw unloadFailure + }, + } + }, + }, + }) + + expect(() => + collection.subscribeChanges(() => {}, { + includeInitialState: true, + onLoadSubsetResult: () => { + throw loadFailure + }, + }), + ).toThrow(loadFailure) + expect(collection.subscriberCount).toBe(0) }) }) diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts new file mode 100644 index 000000000..eabf80b23 --- /dev/null +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -0,0 +1,2024 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createDeferred } from '../src/deferred.js' +import { BTreeIndex } from '../src/indexes/btree-index.js' +import { ReverseIndex } from '../src/indexes/reverse-index.js' +import { Func, PropRef, Value } from '../src/query/ir.js' +import { DeduplicatedLoadSubset } from '../src/query/subset-dedupe.js' +import { createTransaction } from '../src/transactions.js' +import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' +import { flushPromises } from './utils.js' +import type { Collection } from '../src/collection/index.js' +import type { OrderBy } from '../src/query/ir.js' +import type { + ChangeMessageOrDeleteKeyMessage, + LoadSubsetOptions, +} from '../src/types.js' + +type ReplayRow = { + id: `one` | `two` + value: number +} + +type ReplayDemandId = ReplayRow[`id`] + +type ReplayLoad = { + demandId: ReplayDemandId + rows: ReadonlyArray + outcome: `resolve` | `reject` + writeBeforeSettlement?: boolean +} + +type ReplayAttempt = { + loads: ReadonlyArray +} + +type SourceAction = + | { type: `put`; row: ReplayRow } + | { type: `delete`; id: ReplayRow[`id`] } + | { type: `request`; demandId: ReplayDemandId } + +type SourceWriteOrigin = + | { type: `initial`; demandId: ReplayDemandId } + | { type: `replay`; demandId: ReplayDemandId; attemptIndex: number } + | { type: `ordinary` } + +type SourceWrite = { + origin: SourceWriteOrigin + installed: boolean + rows: ReadonlyArray +} + +type ReplayChange = { + type: `insert` | `update` | `delete` + key: string | number + value: ReplayRow + previousValue?: ReplayRow +} + +type ReplayScenario = { + initialRows: ReadonlyArray + demandIds: ReadonlyArray + attempts: ReadonlyArray + settlementOrder: ReadonlyArray + settlementPhases: ReadonlyArray + releaseOnLastAttempt?: ReplayDemandId + afterSettlement: ReadonlyArray +} + +type SequentialReplayLoad = { + rows: ReadonlyArray + outcome: `return` | `throw` | `resolve` | `reject` +} + +type SequentialReplayScenario = { + initialRows: ReadonlyArray + loads: ReadonlyArray +} + +type CleanupRestartScenario = { + oldOutcome: `resolve` | `reject` + newOutcome: `resolve` | `reject` + settleOldFirst: boolean +} + +type SharedSubscriptionScenario = { + outcome: `resolve` | `reject` + releaseCountBeforeSettlement: 0 | 1 | 2 +} + +type OptimisticReplayScenario = { + operation: `insert` | `update` | `delete` + outcome: `resolve` | `reject` + serverRetainsTarget: boolean + initialValue: number + optimisticValue: number + serverValue: number +} + +type PendingReplay = { + attemptIndex: number + load: ReplayLoad + signal: AbortSignal | undefined + deferred: ReturnType> + error: Error + wroteRows: boolean + settled: boolean +} + +const rowArbitrary: fc.Arbitrary = fc.record({ + id: fc.constantFrom(`one` as const, `two` as const), + value: fc.integer({ min: -2, max: 2 }), +}) + +const rowsArbitrary = fc.uniqueArray(rowArbitrary, { + minLength: 0, + maxLength: 2, + selector: ({ id }) => id, +}) + +function replayLoadArbitrary( + demandId: ReplayDemandId, +): fc.Arbitrary { + return fc.record({ + demandId: fc.constant(demandId), + rows: fc + .option(fc.integer({ min: -2, max: 2 }), { nil: undefined }) + .map((value) => (value === undefined ? [] : [{ id: demandId, value }])), + outcome: fc.constantFrom(`resolve` as const, `reject` as const), + writeBeforeSettlement: fc.boolean(), + }) +} + +function sourceActionArbitrary( + demandIds: ReadonlyArray, +): fc.Arbitrary { + return fc.oneof( + fc + .tuple(fc.constantFrom(...demandIds), fc.integer({ min: -2, max: 2 })) + .map(([id, value]) => ({ type: `put` as const, row: { id, value } })), + fc + .constantFrom(...demandIds) + .map((id) => ({ type: `delete` as const, id })), + ) +} + +const replayScenarioArbitrary: fc.Arbitrary = fc + .uniqueArray(fc.constantFrom(`one`, `two`), { + minLength: 1, + maxLength: 2, + }) + .chain((demandIds) => + fc + .record({ + initialRows: rowsArbitrary, + attempts: fc.array( + fc + .tuple( + ...demandIds.map((demandId) => replayLoadArbitrary(demandId)), + ) + .map((loads) => ({ loads })), + { minLength: 1, maxLength: 3 }, + ), + releaseOnLastAttempt: fc.option(fc.constantFrom(...demandIds), { + nil: undefined, + }), + }) + .chain(({ initialRows, attempts, releaseOnLastAttempt }) => { + const replayCount = attempts.length * demandIds.length + const lastAttemptIndex = attempts.length - 1 + return fc + .record({ + settlementOrder: fc.shuffledSubarray( + Array.from({ length: replayCount }, (_, index) => index), + { minLength: replayCount, maxLength: replayCount }, + ), + rawSettlementPhases: fc.array( + fc.integer({ min: 0, max: lastAttemptIndex }), + { minLength: replayCount, maxLength: replayCount }, + ), + afterSettlement: + releaseOnLastAttempt === undefined + ? fc.array(sourceActionArbitrary(demandIds), { + minLength: 0, + maxLength: 3, + }) + : fc.constant>([]), + }) + .map(({ settlementOrder, rawSettlementPhases, afterSettlement }) => ({ + initialRows, + demandIds, + attempts, + settlementOrder, + settlementPhases: rawSettlementPhases.map((phase, replayIndex) => + Math.max(phase, Math.floor(replayIndex / demandIds.length)), + ), + releaseOnLastAttempt, + afterSettlement, + })) + }), + ) + +const sequentialReplayScenarioArbitrary: fc.Arbitrary = + fc.record({ + initialRows: rowsArbitrary, + loads: fc.array( + fc.record({ + rows: rowsArbitrary, + outcome: fc.constantFrom( + `return` as const, + `throw` as const, + `resolve` as const, + `reject` as const, + ), + }), + { minLength: 1, maxLength: 3 }, + ), + }) + +const cleanupRestartScenarioArbitrary: fc.Arbitrary = + fc.record({ + oldOutcome: fc.constantFrom(`resolve` as const, `reject` as const), + newOutcome: fc.constantFrom(`resolve` as const, `reject` as const), + settleOldFirst: fc.boolean(), + }) + +const sharedSubscriptionScenarioArbitrary: fc.Arbitrary = + fc.record({ + outcome: fc.constantFrom(`resolve` as const, `reject` as const), + releaseCountBeforeSettlement: fc.constantFrom( + 0 as const, + 1 as const, + 2 as const, + ), + }) + +const optimisticReplayScenarioArbitrary: fc.Arbitrary = + fc + .record({ + operation: fc.constantFrom( + `insert` as const, + `update` as const, + `delete` as const, + ), + outcome: fc.constantFrom(`resolve` as const, `reject` as const), + serverRetainsTarget: fc.boolean(), + values: fc.uniqueArray(fc.integer({ min: -3, max: 3 }), { + minLength: 3, + maxLength: 3, + }), + }) + .map(({ operation, outcome, serverRetainsTarget, values }) => ({ + operation, + outcome, + serverRetainsTarget, + initialValue: values[0]!, + optimisticValue: values[1]!, + serverValue: values[2]!, + })) + +function rowsById( + rows: ReadonlyArray, +): Map { + return new Map(rows.map((row) => [row.id, { ...row }])) +} + +function sortedRows( + rows: ReadonlyMap, +): Array { + return [...rows.values()].sort((left, right) => + left.id.localeCompare(right.id), + ) +} + +function publicationDiff( + baseline: ReadonlyMap, + finalRows: ReadonlyMap, +): Array { + const changes: Array = [] + for (const [key, previousValue] of baseline) { + const value = finalRows.get(key) + if (!value) { + changes.push({ + type: `delete`, + key, + value: { ...previousValue }, + }) + } else if (value.value !== previousValue.value) { + changes.push({ + type: `update`, + key, + value: { ...value }, + previousValue: { ...previousValue }, + }) + } + } + for (const [key, value] of finalRows) { + if (!baseline.has(key)) { + changes.push({ type: `insert`, key, value: { ...value } }) + } + } + return changes +} + +function sortedChanges( + changes: ReadonlyArray, +): Array { + return [...changes].sort((left, right) => + String(left.key).localeCompare(String(right.key)), + ) +} + +function recordPublishedChanges( + visible: Map, + changes: ReadonlyArray, +): Array { + const recorded = changes.map((change) => ({ + type: change.type, + key: change.key, + value: { id: change.value.id, value: change.value.value }, + ...(change.previousValue === undefined + ? {} + : { + previousValue: { + id: change.previousValue.id, + value: change.previousValue.value, + }, + }), + })) + for (const change of recorded) { + if (change.type === `delete`) visible.delete(change.key) + else visible.set(change.key, { ...change.value }) + } + return recorded +} + +function expectSameSubsetRequest( + actual: LoadSubsetOptions, + expected: LoadSubsetOptions, +): void { + expect(actual.where).toBe(expected.where) + expect(actual.orderBy).toBe(expected.orderBy) + expect(actual.limit).toBe(expected.limit) + expect(actual.cursor).toEqual(expected.cursor) + expect(actual.offset).toBe(expected.offset) +} + +async function runReplayScenario(scenario: ReplayScenario): Promise { + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + let unloadCount = 0 + const leases = new Map< + LoadSubsetOptions, + { acquisitions: number; releases: number } + >() + const queuedLoads: Array<{ attemptIndex: number; load: ReplayLoad }> = [] + const queuedReacquisitions = new Set() + const pendingReplays: Array = [] + const sourceRows = new Map() + const sourceWrites: Array = [] + const expectedSourceWrites: Array = [] + const demandWheres = new Map( + scenario.demandIds.map((demandId) => [ + demandId, + new Func(`eq`, [new PropRef([`id`]), new Value(demandId)]), + ]), + ) + const demandIdByWhere = new Map< + NonNullable, + ReplayDemandId + >([...demandWheres].map(([demandId, where]) => [where, demandId])) + const requestByDemand = new Map() + const activeDemandIds = new Set(scenario.demandIds) + + const recordExpectedSourceWrite = ( + rows: ReadonlyArray, + origin: SourceWriteOrigin, + installed: boolean, + ) => { + expectedSourceWrites.push({ + origin, + installed, + rows: rows.map((row) => ({ ...row })), + }) + } + + const assertSourceWrites = () => { + expect(sourceWrites).toEqual(expectedSourceWrites) + } + + const applyRows = ( + rows: ReadonlyArray, + origin: SourceWriteOrigin, + signal?: AbortSignal, + ): boolean => { + const installed = !signal?.aborted + sourceWrites.push({ + origin, + installed, + rows: rows.map((row) => ({ ...row })), + }) + if (!installed || rows.length === 0) return installed + begin() + for (const row of rows) { + write({ + type: sourceRows.has(row.id) ? `update` : `insert`, + value: { ...row }, + }) + } + commit() + for (const row of rows) sourceRows.set(row.id, { ...row }) + return true + } + + const collection: Collection = + createCollection({ + id: `subscription-replay-oracle`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + loadCount++ + const lease = leases.get(options) ?? { + acquisitions: 0, + releases: 0, + } + lease.acquisitions++ + leases.set(options, lease) + const demandId = + options.where === undefined + ? undefined + : demandIdByWhere.get(options.where) + if (demandId === undefined) { + throw new Error(`Subset request did not preserve its demand`) + } + + if (!requestByDemand.has(demandId)) { + requestByDemand.set(demandId, options) + applyRows( + scenario.initialRows.filter(({ id }) => id === demandId), + { type: `initial`, demandId }, + ) + return true + } + + const queuedIndex = queuedLoads.findIndex( + ({ load }) => load.demandId === demandId, + ) + if (queuedIndex === -1) { + if (queuedReacquisitions.delete(demandId)) { + expectSameSubsetRequest( + options, + requestByDemand.get(demandId)!, + ) + return true + } + throw new Error(`Replay load was not queued for ${demandId}`) + } + const [queued] = queuedLoads.splice(queuedIndex, 1) + if (!queued) throw new Error(`Replay queue changed unexpectedly`) + expectSameSubsetRequest(options, requestByDemand.get(demandId)!) + const pending: PendingReplay = { + attemptIndex: queued.attemptIndex, + load: queued.load, + signal: options.signal, + deferred: createDeferred(), + error: new Error(`Replay rejected`), + wroteRows: false, + settled: false, + } + pendingReplays.push(pending) + return pending.deferred.promise + }, + unloadSubset: (options) => { + unloadCount++ + const lease = leases.get(options) ?? { + acquisitions: 0, + releases: 0, + } + lease.releases++ + leases.set(options, lease) + }, + } + }, + }, + }) + + const visible = new Map() + let publicationCount = 0 + const publicationBatches: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + publicationCount++ + publicationBatches.push(recordPublishedChanges(visible, changes)) + }) + const reportedErrors: Array = [] + subscription.on(`loadSubset:error`, ({ error }) => reportedErrors.push(error)) + let unsubscribed = false + + const assertPublished = ( + expected: ReadonlyMap, + ) => { + expect(sortedRows(visible)).toEqual(sortedRows(expected)) + } + + const assertSource = () => { + const actual = rowsById( + collection.toArray.map(({ id, value }) => ({ id, value })), + ) + expect(sortedRows(actual)).toEqual(sortedRows(sourceRows)) + } + + const applySourceAction = (action: SourceAction): boolean => { + if (action.type === `request`) return false + if (action.type === `delete`) { + const previous = sourceRows.get(action.id) + if (!previous) return false + begin() + write({ type: `delete`, key: action.id }) + commit() + sourceRows.delete(action.id) + return true + } + + const previous = sourceRows.get(action.row.id) + if (previous?.value === action.row.value) return false + applyRows([action.row], { type: `ordinary` }) + return true + } + + try { + for (const demandId of scenario.demandIds) { + subscription.requestSnapshot({ + optimizedOnly: false, + where: demandWheres.get(demandId), + }) + recordExpectedSourceWrite( + scenario.initialRows.filter(({ id }) => id === demandId), + { type: `initial`, demandId }, + true, + ) + assertSourceWrites() + } + const expectedPublished = rowsById( + scenario.initialRows.filter(({ id }) => activeDemandIds.has(id)), + ) + assertPublished(expectedPublished) + assertSource() + let expectedPublicationCount = publicationCount + let lastReportedError: Error | undefined + let modelSession: + | { + baseline: Map + pending: Set + currentAttemptIndex: number + publicationCount: number + } + | undefined + + const writeReplayRows = ( + pending: PendingReplay, + isCurrent: boolean, + ): void => { + if (pending.wroteRows) return + const load = pending.load + recordExpectedSourceWrite( + load.rows, + { + type: `replay`, + demandId: load.demandId, + attemptIndex: pending.attemptIndex, + }, + isCurrent, + ) + const installed = applyRows( + load.rows, + { + type: `replay`, + demandId: load.demandId, + attemptIndex: pending.attemptIndex, + }, + pending.signal, + ) + pending.wroteRows = true + expect(installed).toBe(isCurrent) + assertSourceWrites() + } + + const settleReplay = async (replayIndex: number) => { + const pending = pendingReplays[replayIndex]! + const session = modelSession! + const load = pending.load + const isCurrent = + pending.attemptIndex === session.currentAttemptIndex && + activeDemandIds.has(load.demandId) + pending.settled = true + if (load.outcome === `resolve`) { + writeReplayRows(pending, isCurrent) + pending.deferred.resolve() + } else { + if (isCurrent) { + lastReportedError = pending.error + } else { + expect(pending.signal?.aborted).toBe(true) + } + pending.deferred.reject(pending.error) + } + session.pending.delete(replayIndex) + await flushPromises() + assertSource() + + const hasPendingReplay = pendingReplays.some(({ settled }) => !settled) + expect(subscription.status).toBe( + hasPendingReplay ? `loadingSubset` : `ready`, + ) + + if (session.pending.size === 0) { + const currentAttempt = scenario.attempts[session.currentAttemptIndex]! + const currentAttemptSucceeds = currentAttempt.loads.every( + ({ demandId, outcome }) => + !activeDemandIds.has(demandId) || outcome === `resolve`, + ) + const previousPublication = new Map(expectedPublished) + expectedPublished.clear() + const nextRows = currentAttemptSucceeds + ? rowsById( + currentAttempt.loads.flatMap(({ demandId, rows }) => + activeDemandIds.has(demandId) ? rows : [], + ), + ) + : session.baseline + for (const [id, row] of nextRows) { + expectedPublished.set(id, { ...row }) + } + + if (currentAttemptSucceeds) { + const expectedBatch = publicationDiff( + previousPublication, + expectedPublished, + ) + expect(publicationCount - session.publicationCount).toBe( + Number(expectedBatch.length > 0), + ) + if (expectedBatch.length > 0) { + expect(sortedChanges(publicationBatches.at(-1)!)).toEqual( + sortedChanges(expectedBatch), + ) + } + } else { + expect(publicationCount).toBe(session.publicationCount) + } + expectedPublicationCount = publicationCount + modelSession = undefined + } else { + expect(publicationCount).toBe(session.publicationCount) + } + + assertPublished(expectedPublished) + expect(subscription.lastError).toBe(lastReportedError) + expect(reportedErrors.at(-1)).toBe(lastReportedError) + } + + for (const [attemptIndex, attempt] of scenario.attempts.entries()) { + modelSession ??= { + baseline: new Map(expectedPublished), + pending: new Set(), + currentAttemptIndex: attemptIndex, + publicationCount: expectedPublicationCount, + } + modelSession.currentAttemptIndex = attemptIndex + + for (const load of attempt.loads) { + queuedLoads.push({ attemptIndex, load }) + } + const firstReplayIndex = pendingReplays.length + begin() + truncate() + commit() + sourceRows.clear() + await flushPromises() + for ( + let replayIndex = firstReplayIndex; + replayIndex < pendingReplays.length; + replayIndex++ + ) { + modelSession.pending.add(replayIndex) + const pending = pendingReplays[replayIndex]! + if (pending.load.writeBeforeSettlement) { + writeReplayRows(pending, true) + } + } + if ( + attemptIndex === scenario.attempts.length - 1 && + scenario.releaseOnLastAttempt !== undefined + ) { + subscription.releaseSnapshot( + demandWheres.get(scenario.releaseOnLastAttempt)!, + ) + activeDemandIds.delete(scenario.releaseOnLastAttempt) + } + assertSource() + assertPublished(expectedPublished) + expect(publicationCount).toBe(modelSession.publicationCount) + expect(subscription.lastError).toBe(lastReportedError) + expect(subscription.status).toBe(`loadingSubset`) + + for (const replayIndex of scenario.settlementOrder) { + const replay = pendingReplays[replayIndex] + if ( + replay && + !replay.settled && + scenario.settlementPhases[replayIndex] === attemptIndex + ) { + await settleReplay(replayIndex) + } + } + } + + expect(modelSession).toBeUndefined() + + for (const action of scenario.afterSettlement) { + const countBeforeAction = publicationCount + const previousPublication = new Map(expectedPublished) + if (action.type === `request`) { + queuedReacquisitions.add(action.demandId) + activeDemandIds.add(action.demandId) + subscription.requestSnapshot({ + optimizedOnly: false, + where: demandWheres.get(action.demandId), + }) + const row = sourceRows.get(action.demandId) + if (row) expectedPublished.set(action.demandId, { ...row }) + } + const applied = applySourceAction(action) + if (applied && action.type === `delete`) { + expectedPublished.delete(action.id) + } else if (applied && action.type === `put`) { + recordExpectedSourceWrite([action.row], { type: `ordinary` }, true) + assertSourceWrites() + expectedPublished.set(action.row.id, { ...action.row }) + } + assertSource() + assertPublished(expectedPublished) + const expectedBatch = publicationDiff( + previousPublication, + expectedPublished, + ) + expect(publicationCount).toBe( + countBeforeAction + Number(expectedBatch.length > 0), + ) + if (expectedBatch.length > 0) { + expect(sortedChanges(publicationBatches.at(-1)!)).toEqual( + sortedChanges(expectedBatch), + ) + } + } + + subscription.unsubscribe() + unsubscribed = true + expect(unloadCount).toBe(loadCount) + for (const lease of leases.values()) { + expect(lease).toEqual({ acquisitions: 1, releases: 1 }) + } + assertSourceWrites() + } finally { + for (const replay of pendingReplays) { + if (!replay.settled) replay.deferred.resolve() + } + await flushPromises() + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } +} + +async function runSequentialReplayScenario( + scenario: SequentialReplayScenario, +): Promise { + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let nextLoad: SequentialReplayLoad | undefined + let nextError: Error | undefined + let initialLoad = true + const sourceRows = new Map() + const leases = new Map< + LoadSubsetOptions, + { acquisitions: number; releases: number } + >() + const pending: Array<{ + load: SequentialReplayLoad + deferred: ReturnType> + error: Error + }> = [] + + const applyRows = (rows: ReadonlyArray) => { + if (rows.length === 0) return + begin() + for (const row of rows) { + write({ + type: sourceRows.has(row.id) ? `update` : `insert`, + value: { ...row }, + }) + } + commit() + for (const row of rows) sourceRows.set(row.id, { ...row }) + } + + const collection = createCollection({ + id: `sequential-replay-oracle`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if (initialLoad) { + initialLoad = false + applyRows(scenario.initialRows) + leases.set(options, { acquisitions: 1, releases: 0 }) + return true + } + + const load = nextLoad + if (!load) throw new Error(`Sequential replay was not queued`) + const error = nextError + if (!error) + throw new Error(`Sequential replay error was not queued`) + nextLoad = undefined + nextError = undefined + applyRows(load.rows) + if (load.outcome === `throw`) { + throw error + } + + leases.set(options, { acquisitions: 1, releases: 0 }) + if (load.outcome === `return`) return true + const deferred = createDeferred() + pending.push({ load, deferred, error }) + return deferred.promise + }, + unloadSubset: (options) => { + const lease = leases.get(options) + if (!lease) { + throw new Error(`Released an acquisition that never returned`) + } + lease.releases++ + }, + } + }, + }, + }) + const visible = new Map() + let publicationCount = 0 + const publicationBatches: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + publicationCount++ + publicationBatches.push(recordPublishedChanges(visible, changes)) + }) + const reportedErrors: Array = [] + subscription.on(`loadSubset:error`, ({ error }) => reportedErrors.push(error)) + let unsubscribed = false + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + const expectedPublished = rowsById(scenario.initialRows) + let expectedLastError: unknown + + for (const load of scenario.loads) { + const baseline = new Map(expectedPublished) + const publicationBefore = publicationCount + const pendingBefore = pending.length + const expectedError = new Error( + load.outcome === `throw` + ? `Synchronous replay failure` + : `Asynchronous replay failure`, + ) + nextLoad = load + nextError = expectedError + begin() + truncate() + commit() + sourceRows.clear() + await flushPromises() + + const pendingLoad = pending[pendingBefore] + if (load.outcome === `resolve`) pendingLoad?.deferred.resolve() + if (load.outcome === `reject`) { + pendingLoad?.deferred.reject(pendingLoad.error) + } + await flushPromises() + + const succeeded = load.outcome === `return` || load.outcome === `resolve` + if (succeeded) { + expectedPublished.clear() + for (const [id, row] of sourceRows) { + expectedPublished.set(id, { ...row }) + } + } else { + expectedPublished.clear() + for (const [id, row] of baseline) expectedPublished.set(id, { ...row }) + expectedLastError = expectedError + } + + const expectedBatch = succeeded + ? publicationDiff(baseline, expectedPublished) + : [] + expect(publicationCount - publicationBefore).toBe( + Number(expectedBatch.length > 0), + ) + if (expectedBatch.length > 0) { + expect(sortedChanges(publicationBatches.at(-1)!)).toEqual( + sortedChanges(expectedBatch), + ) + } + expect(sortedRows(visible)).toEqual(sortedRows(expectedPublished)) + expect( + sortedRows( + rowsById(collection.toArray.map(({ id, value }) => ({ id, value }))), + ), + ).toEqual(sortedRows(sourceRows)) + expect(subscription.status).toBe(`ready`) + expect(subscription.lastError).toBe(expectedLastError) + expect(reportedErrors.at(-1)).toBe(expectedLastError) + } + + subscription.unsubscribe() + unsubscribed = true + for (const lease of leases.values()) { + expect(lease).toEqual({ acquisitions: 1, releases: 1 }) + } + } finally { + for (const load of pending) load.deferred.resolve() + await flushPromises() + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } +} + +async function runCleanupRestartScenario( + scenario: CleanupRestartScenario, +): Promise { + const sessions: Array<{ + begin: () => void + write: (message: ChangeMessageOrDeleteKeyMessage) => void + commit: () => void + }> = [] + const loads: Array<{ + session: number + deferred: ReturnType> + }> = [] + let session = 0 + const collection = createCollection({ + id: `cleanup-restart-oracle`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + const currentSession = session++ + sessions.push({ begin, write, commit }) + markReady() + return { + loadSubset: () => { + const deferred = createDeferred() + loads.push({ session: currentSession, deferred }) + return deferred.promise + }, + } + }, + }, + }) + + const settle = async (loadIndex: number, outcome: `resolve` | `reject`) => { + const load = loads[loadIndex]! + if (outcome === `resolve`) load.deferred.resolve() + else load.deferred.reject(new Error(`session ${load.session} failed`)) + await flushPromises() + } + + try { + const oldResult = collection._sync.loadSubset({}) + expect(oldResult).toBeInstanceOf(Promise) + if (oldResult instanceof Promise) void oldResult.catch(() => {}) + expect(collection.isLoadingSubset).toBe(true) + + await collection.cleanup() + expect(collection.isLoadingSubset).toBe(false) + + collection.startSyncImmediate() + const newResult = collection._sync.loadSubset({}) + expect(newResult).toBeInstanceOf(Promise) + if (newResult instanceof Promise) void newResult.catch(() => {}) + expect(loads.map(({ session: loadSession }) => loadSession)).toEqual([0, 1]) + expect(collection.isLoadingSubset).toBe(true) + + const oldSession = sessions[0]! + oldSession.begin() + oldSession.write({ type: `insert`, value: { id: `one`, value: 1 } }) + oldSession.commit() + expect(collection.toArray).toEqual([]) + + const currentSession = sessions[1]! + currentSession.begin() + currentSession.write({ type: `insert`, value: { id: `two`, value: 2 } }) + currentSession.commit() + expect(collection.toArray.map(({ id, value }) => ({ id, value }))).toEqual([ + { id: `two`, value: 2 }, + ]) + + const settlementOrder = scenario.settleOldFirst ? [0, 1] : [1, 0] + const outcomes = [scenario.oldOutcome, scenario.newOutcome] as const + let newSettled = false + for (const loadIndex of settlementOrder) { + await settle(loadIndex, outcomes[loadIndex]!) + if (loadIndex === 1) newSettled = true + expect(collection.isLoadingSubset).toBe(!newSettled) + expect( + collection.toArray.map(({ id, value }) => ({ id, value })), + ).toEqual([{ id: `two`, value: 2 }]) + } + } finally { + for (const { deferred } of loads) deferred.resolve() + await flushPromises() + await collection.cleanup() + } +} + +async function runSharedSubscriptionScenario( + scenario: SharedSubscriptionScenario, +): Promise { + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + const transport = createDeferred() + let transportOptions: LoadSubsetOptions | undefined + let transportCalls = 0 + const unloads: Array = [] + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + transportCalls++ + transportOptions = options + return transport.promise + }, + }) + const collection = createCollection({ + id: `shared-subscription-oracle`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: dedupe.loadSubset, + unloadSubset: (options) => unloads.push(options), + } + }, + }, + }) + const visible = [ + new Map(), + new Map(), + ] as const + const subscribe = (rows: Map) => + collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) rows.delete(change.key) + else { + rows.set(change.key, { + id: change.value.id, + value: change.value.value, + }) + } + } + }) + const subscriptions = [subscribe(visible[0]), subscribe(visible[1])] as const + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) + let firstUnsubscribed = false + let secondUnsubscribed = false + + try { + subscriptions[0].requestSnapshot({ where }) + subscriptions[1].requestSnapshot({ where }) + expect(transportCalls).toBe(1) + expect(subscriptions[0].status).toBe(`loadingSubset`) + expect(subscriptions[1].status).toBe(`loadingSubset`) + + if (scenario.releaseCountBeforeSettlement >= 1) { + subscriptions[0].unsubscribe() + firstUnsubscribed = true + expect(transportOptions?.signal?.aborted).toBe(false) + } + if (scenario.releaseCountBeforeSettlement === 2) { + subscriptions[1].unsubscribe() + secondUnsubscribed = true + expect(transportOptions?.signal?.aborted).toBe(true) + } + + const failure = new Error(`shared transport failed`) + if (scenario.outcome === `resolve`) { + if (!transportOptions?.signal?.aborted) { + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + } + transport.resolve() + } else { + transport.reject( + transportOptions?.signal?.aborted + ? new DOMException(`obsolete`, `AbortError`) + : failure, + ) + } + await flushPromises() + + if (!secondUnsubscribed) { + expect(subscriptions[1].status).toBe(`ready`) + expect(subscriptions[1].lastError).toBe( + scenario.outcome === `reject` ? failure : undefined, + ) + expect([...visible[1].values()]).toEqual( + scenario.outcome === `resolve` ? [{ id: `one`, value: 1 }] : [], + ) + } else { + expect(subscriptions[1].lastError).toBeUndefined() + expect([...visible[1].values()]).toEqual([]) + } + if (firstUnsubscribed) { + expect(subscriptions[0].lastError).toBeUndefined() + } else { + expect(subscriptions[0].lastError).toBe( + scenario.outcome === `reject` ? failure : undefined, + ) + } + + if (!firstUnsubscribed) { + subscriptions[0].unsubscribe() + firstUnsubscribed = true + } + if (!secondUnsubscribed) { + subscriptions[1].unsubscribe() + secondUnsubscribed = true + } + expect(unloads).toHaveLength(2) + expect(new Set(unloads).size).toBe(2) + } finally { + transport.resolve() + await flushPromises() + if (!firstUnsubscribed) subscriptions[0].unsubscribe() + if (!secondUnsubscribed) subscriptions[1].unsubscribe() + await collection.cleanup() + } +} + +function applyOptimisticOperation( + source: ReadonlyMap, + scenario: OptimisticReplayScenario, +): Map { + const result = new Map( + [...source].map(([key, row]) => [key, { ...row }] as const), + ) + if (scenario.operation === `insert`) { + result.set(`two`, { id: `two`, value: scenario.optimisticValue }) + } else if (scenario.operation === `update`) { + result.set(`one`, { id: `one`, value: scenario.optimisticValue }) + } else { + result.delete(`one`) + } + return result +} + +async function runOptimisticReplayScenario( + scenario: OptimisticReplayScenario, +): Promise { + let begin!: (options?: { immediate?: boolean }) => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const replay = createDeferred() + const replayFailure = new Error(`optimistic replay failed`) + const mutation = createDeferred() + const initialSource = rowsById([{ id: `one`, value: scenario.initialValue }]) + const replayRows = + scenario.operation === `insert` + ? [ + { id: `one` as const, value: scenario.serverValue }, + ...(scenario.serverRetainsTarget + ? [{ id: `two` as const, value: scenario.serverValue }] + : []), + ] + : scenario.serverRetainsTarget + ? [{ id: `one` as const, value: scenario.serverValue }] + : [] + const replaySource = rowsById(replayRows) + const collection = createCollection({ + id: `optimistic-replay-${scenario.operation}-${scenario.outcome}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount === 1) { + begin() + for (const row of initialSource.values()) { + write({ type: `insert`, value: { ...row } }) + } + commit() + return true + } + return replay.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const visible = new Map() + const batches: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + batches.push(recordPublishedChanges(visible, changes)) + }) + const transaction = createTransaction({ + mutationFn: () => mutation.promise, + }) + void transaction.isPersisted.promise.catch(() => {}) + let unsubscribed = false + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + expect(sortedRows(visible)).toEqual(sortedRows(initialSource)) + + transaction.mutate(() => { + if (scenario.operation === `insert`) { + collection.insert({ id: `two`, value: scenario.optimisticValue }) + } else if (scenario.operation === `update`) { + collection.update(`one`, (draft) => { + draft.value = scenario.optimisticValue + }) + } else { + collection.delete(`one`) + } + }) + const optimisticBaseline = applyOptimisticOperation(initialSource, scenario) + expect(sortedRows(visible)).toEqual(sortedRows(optimisticBaseline)) + expect( + sortedRows( + rowsById(collection.toArray.map(({ id, value }) => ({ id, value }))), + ), + ).toEqual(sortedRows(optimisticBaseline)) + batches.length = 0 + + begin() + truncate() + commit() + await flushPromises() + // A loadSubset adapter must install its request-scoped rows before its + // promise settles, even while a user mutation is still persisting. + begin({ immediate: true }) + for (const row of replayRows) { + write({ type: `insert`, value: { ...row } }) + } + commit() + if (scenario.outcome === `resolve`) replay.resolve() + else replay.reject(replayFailure) + await flushPromises() + + const expected = applyOptimisticOperation( + scenario.outcome === `resolve` ? replaySource : initialSource, + scenario, + ) + const expectedBatch = + scenario.outcome === `resolve` + ? publicationDiff(optimisticBaseline, expected) + : [] + expect(sortedRows(visible)).toEqual(sortedRows(expected)) + expect(batches.map(sortedChanges)).toEqual( + expectedBatch.length > 0 ? [sortedChanges(expectedBatch)] : [], + ) + expect(subscription.lastError).toBe( + scenario.outcome === `reject` ? replayFailure : undefined, + ) + + subscription.unsubscribe() + unsubscribed = true + } finally { + replay.resolve() + mutation.resolve() + await flushPromises() + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } +} + +const { multiplier, replaySeed } = readOracleRunConfig() +const generatedRuns = 30 * multiplier + +describe(`CollectionSubscription replay oracle`, () => { + it(`aborts an in-flight initial acquisition before its replay replaces it`, async () => { + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + const loads: Array<{ + options: LoadSubsetOptions + deferred: ReturnType> + }> = [] + const collection = createCollection({ + id: `initial-acquisition-replay`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + const deferred = createDeferred() + loads.push({ options, deferred }) + return deferred.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else { + visible.set(change.key, { + id: change.value.id, + value: change.value.value, + }) + } + } + }) + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + begin() + truncate() + commit() + await flushPromises() + expect(loads[0]?.options.signal?.aborted).toBe(true) + + begin() + write({ type: `insert`, value: { id: `two`, value: 2 } }) + commit() + loads[1]?.deferred.resolve() + await flushPromises() + + if (!loads[0]?.options.signal?.aborted) { + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + } + loads[0]?.deferred.resolve() + await flushPromises() + + expect(sortedRows(visible)).toEqual([{ id: `two`, value: 2 }]) + expect(subscription.status).toBe(`ready`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`uses the published replacement as the baseline of a reentrant replay`, async () => { + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const replayLoads: Array>> = [] + const collection = createCollection({ + id: `reentrant-replay`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + return true + } + + const deferred = createDeferred() + replayLoads.push(deferred) + return deferred.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const visible = new Map() + let startedNestedReplay = false + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else { + visible.set(change.key, { + id: change.value.id, + value: change.value.value, + }) + } + } + + if (!startedNestedReplay && visible.get(`one`)?.value === 2) { + startedNestedReplay = true + begin() + truncate() + commit() + } + }) + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + begin() + truncate() + commit() + await flushPromises() + begin() + write({ type: `insert`, value: { id: `one`, value: 2 } }) + commit() + replayLoads[0]?.resolve() + await flushPromises() + expect(startedNestedReplay).toBe(true) + + replayLoads[1]?.reject(new Error(`nested replay failed`)) + await flushPromises() + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + + expect(sortedRows(visible)).toEqual([{ id: `one`, value: 1 }]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`ignores an aborted released demand while publishing the remaining replay`, async () => { + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + const replays: Array<{ + options: { signal?: AbortSignal } + deferred: ReturnType> + }> = [] + let loadCount = 0 + const collection = createCollection({ + id: `released-demand-replay`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `one`, value: 0 } }) + write({ type: `insert`, value: { id: `two`, value: 0 } }) + commit() + return true + } + if (loadCount === 2) return true + + const deferred = createDeferred() + replays.push({ options, deferred }) + return deferred.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else { + visible.set(change.key, { + id: change.value.id, + value: change.value.value, + }) + } + } + }) + const demandOne = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) + const demandTwo = new Func(`eq`, [new PropRef([`id`]), new Value(`two`)]) + + try { + subscription.requestSnapshot({ where: demandOne }) + subscription.requestSnapshot({ where: demandTwo }) + begin() + truncate() + commit() + await flushPromises() + + subscription.releaseSnapshot(demandOne) + expect(replays[0]?.options.signal?.aborted).toBe(true) + replays[0]?.deferred.reject(new DOMException(`obsolete`, `AbortError`)) + begin() + write({ type: `insert`, value: { id: `two`, value: 2 } }) + commit() + replays[1]?.deferred.resolve() + await flushPromises() + + expect(sortedRows(visible)).toEqual([{ id: `two`, value: 2 }]) + expect(subscription.lastError).toBeUndefined() + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + const orderedReplayCases = ([`asc`, `desc`] as const).flatMap((direction) => [ + ...([`return`, `resolve`] as const).flatMap((delivery) => + ([`same`, `changed`] as const).map((identity) => ({ + name: `${direction} ${delivery} with ${identity} keys`, + direction, + delivery, + identity, + })), + ), + ...([`throw`, `reject`] as const).map((delivery) => ({ + name: `${direction} ${delivery}`, + direction, + delivery, + identity: `none` as const, + })), + ]) + + it.each(orderedReplayCases)( + `restores ordered offset and cursor state after replay: $name`, + async ({ direction, delivery, identity }) => { + type OrderedReplayRow = { + id: `one` | `two` | `three` | `four` + value: number + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const loadOptions: Array = [] + const replayLoads: Array>> = [] + const replayRows: ReadonlyArray = + identity === `same` + ? [ + { id: `one`, value: 1 }, + { id: `two`, value: 2 }, + ] + : [ + { id: `three`, value: 1 }, + { id: `four`, value: 2 }, + ] + let replayRowsInstalled = false + const installReplayRows = () => { + if (replayRowsInstalled || identity === `none`) return + replayRowsInstalled = true + begin() + for (const row of replayRows) { + write({ type: `insert`, value: row }) + } + commit() + } + const collection = createCollection({ + id: `ordered-replay-${direction}-${delivery}-${identity}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + write({ type: `insert`, value: { id: `two`, value: 2 } }) + commit() + params.markReady() + return { + loadSubset: (options) => { + loadCount++ + loadOptions.push(options) + if (loadCount <= 2) return true + if (loadCount > 4) return true + + if (delivery === `return`) { + installReplayRows() + return true + } + if (delivery === `throw`) { + if (loadCount === 3) { + throw new Error(`ordered replay failed`) + } + return true + } + + const deferred = createDeferred() + replayLoads.push(deferred) + return deferred.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.value, { + indexType: BTreeIndex, + }) + const orderedIndex = direction === `asc` ? index : new ReverseIndex(index) + const orderBy: OrderBy = [ + { + expression: new PropRef([`value`]), + compareOptions: { direction, nulls: `first` }, + }, + ] + const batches: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + batches.push(changes.map(({ value }) => value.id)) + }) + subscription.setOrderByIndex(orderedIndex) + + const initialIds = + direction === `asc` + ? ([`one`, `two`] as const) + : ([`two`, `one`] as const) + const replacementIds = + direction === `asc` + ? ([`three`, `four`] as const) + : ([`four`, `three`] as const) + const succeeds = delivery === `return` || delivery === `resolve` + const expectedIds = identity === `changed` ? replacementIds : initialIds + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + expect(batches).toEqual([[initialIds[0]]]) + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + minValues: [direction === `asc` ? 1 : 2], + }) + expect(loadOptions[1]).toMatchObject({ + offset: 1, + cursor: { lastKey: initialIds[1] }, + }) + + begin() + truncate() + commit() + await flushPromises() + expectSameSubsetRequest(loadOptions[2]!, loadOptions[0]!) + expectSameSubsetRequest(loadOptions[3]!, loadOptions[1]!) + + if (delivery === `resolve`) { + expect(replayLoads).toHaveLength(2) + installReplayRows() + replayLoads[0]?.resolve() + replayLoads[1]?.resolve() + } else if (delivery === `reject`) { + expect(replayLoads).toHaveLength(2) + replayLoads[0]?.reject(new Error(`ordered replay failed`)) + replayLoads[1]?.resolve() + } else { + expect(replayLoads).toEqual([]) + } + await flushPromises() + expect(collection.toArray.map(({ id }) => id).sort()).toEqual( + succeeds ? [...expectedIds].sort() : [], + ) + + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + minValues: [direction === `asc` ? 2 : 1], + }) + expect(loadOptions[4]).toMatchObject({ + offset: 2, + cursor: { + lastKey: succeeds ? expectedIds[1] : initialIds[1], + }, + }) + expect(batches.at(-1)).toEqual([]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it(`publishes a same-key replacement after a failed replay`, async () => { + await runReplayScenario({ + initialRows: [{ id: `one`, value: 1 }], + demandIds: [`one`], + attempts: [ + { + loads: [{ demandId: `one`, rows: [], outcome: `reject` }], + }, + ], + settlementOrder: [0], + settlementPhases: [0], + afterSettlement: [{ type: `put`, row: { id: `one`, value: 2 } }], + }) + }) + + it(`does not let an unpublished truncate delete suppress a later insert`, async () => { + await runReplayScenario({ + initialRows: [], + demandIds: [`two`, `one`], + attempts: [ + { + loads: [ + { + demandId: `two`, + rows: [{ id: `two`, value: -1 }], + outcome: `resolve`, + }, + { demandId: `one`, rows: [], outcome: `reject` }, + ], + }, + { + loads: [ + { demandId: `two`, rows: [], outcome: `resolve` }, + { + demandId: `one`, + rows: [{ id: `one`, value: -1 }], + outcome: `resolve`, + }, + ], + }, + ], + settlementOrder: [0, 1, 2, 3], + settlementPhases: [0, 0, 1, 1], + afterSettlement: [{ type: `put`, row: { id: `two`, value: 1 } }], + }) + }) + + it(`lets the newest successful replay replace an older failed replay`, async () => { + await runReplayScenario({ + initialRows: [{ id: `one`, value: 1 }], + demandIds: [`one`], + attempts: [ + { + loads: [{ demandId: `one`, rows: [], outcome: `reject` }], + }, + { + loads: [ + { + demandId: `one`, + rows: [{ id: `one`, value: 2 }], + outcome: `resolve`, + }, + ], + }, + ], + settlementOrder: [1, 0], + settlementPhases: [1, 1], + afterSettlement: [], + }) + }) + + it(`ignores an obsolete replay that settles after the newest replay`, async () => { + await runReplayScenario({ + initialRows: [{ id: `one`, value: 0 }], + demandIds: [`one`], + attempts: [ + { + loads: [ + { + demandId: `one`, + rows: [{ id: `one`, value: 1 }], + outcome: `resolve`, + }, + ], + }, + { + loads: [ + { + demandId: `one`, + rows: [{ id: `one`, value: 2 }], + outcome: `resolve`, + }, + ], + }, + ], + settlementOrder: [1, 0], + settlementPhases: [1, 1], + afterSettlement: [], + }) + }) + + it(`releases every successful overlapping replay acquisition`, async () => { + await runReplayScenario({ + initialRows: [], + demandIds: [`one`], + attempts: [ + { + loads: [{ demandId: `one`, rows: [], outcome: `resolve` }], + }, + { + loads: [{ demandId: `one`, rows: [], outcome: `resolve` }], + }, + ], + settlementOrder: [1, 0], + settlementPhases: [1, 1], + afterSettlement: [], + }) + }) + + it(`uses the newest complete multi-demand replay`, async () => { + await runReplayScenario({ + initialRows: [{ id: `one`, value: 1 }], + demandIds: [`one`, `two`], + attempts: [ + { + loads: [ + { + demandId: `one`, + rows: [{ id: `one`, value: 2 }], + outcome: `resolve`, + }, + { demandId: `two`, rows: [], outcome: `reject` }, + ], + }, + { + loads: [ + { + demandId: `one`, + rows: [{ id: `one`, value: 3 }], + outcome: `resolve`, + }, + { + demandId: `two`, + rows: [{ id: `two`, value: 4 }], + outcome: `resolve`, + }, + ], + }, + ], + settlementOrder: [2, 3, 0, 1], + settlementPhases: [1, 1, 1, 1], + afterSettlement: [], + }) + }) + + it(`excludes rows written before their replay demand is released`, async () => { + await runReplayScenario({ + initialRows: [{ id: `two`, value: 0 }], + demandIds: [`one`, `two`], + attempts: [ + { + loads: [ + { + demandId: `one`, + rows: [{ id: `one`, value: 1 }], + outcome: `reject`, + writeBeforeSettlement: true, + }, + { + demandId: `two`, + rows: [{ id: `two`, value: 2 }], + outcome: `resolve`, + }, + ], + }, + ], + settlementOrder: [0, 1], + settlementPhases: [0, 0], + releaseOnLastAttempt: `one`, + afterSettlement: [{ type: `request`, demandId: `one` }], + }) + }) + + it(`replaces a retained snapshot with a later empty replay`, async () => { + await runReplayScenario({ + initialRows: [{ id: `one`, value: 1 }], + demandIds: [`one`], + attempts: [ + { + loads: [{ demandId: `one`, rows: [], outcome: `reject` }], + }, + { + loads: [{ demandId: `one`, rows: [], outcome: `resolve` }], + }, + ], + settlementOrder: [0, 1], + settlementPhases: [0, 1], + afterSettlement: [], + }) + }) + + fcTest.prop([replayScenarioArbitrary], { + numRuns: generatedRuns, + seed: 1756, + })(`matches replay and ownership laws for a fixed seed`, runReplayScenario) + + fcTest.prop( + [replayScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `matches replay and ownership laws for a random or replayed seed`, + runReplayScenario, + ) + + fcTest.prop( + [sequentialReplayScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `matches synchronous, asynchronous, and partial-failure replay laws`, + runSequentialReplayScenario, + ) + + fcTest.prop([cleanupRestartScenarioArbitrary], { + numRuns: generatedRuns, + seed: 1757, + })( + `isolates cleanup and restart sessions for a fixed seed`, + runCleanupRestartScenario, + ) + + fcTest.prop( + [cleanupRestartScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `isolates cleanup and restart sessions for a random or replayed seed`, + runCleanupRestartScenario, + ) + + fcTest.prop([sharedSubscriptionScenarioArbitrary], { + numRuns: generatedRuns, + seed: 1758, + })( + `keeps shared transport and logical ownership distinct for a fixed seed`, + runSharedSubscriptionScenario, + ) + + fcTest.prop( + [sharedSubscriptionScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `keeps shared transport and logical ownership distinct for a random or replayed seed`, + runSharedSubscriptionScenario, + ) + + fcTest.prop([optimisticReplayScenarioArbitrary], { + numRuns: generatedRuns, + seed: 1759, + })( + `preserves optimistic overlays across replay outcomes for a fixed seed`, + runOptimisticReplayScenario, + ) + + fcTest.prop( + [optimisticReplayScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `preserves optimistic overlays across replay outcomes for a random or replayed seed`, + runOptimisticReplayScenario, + ) +}) diff --git a/packages/db/tests/collection-subscription.test.ts b/packages/db/tests/collection-subscription.test.ts index 65465a6a8..dc4444b55 100644 --- a/packages/db/tests/collection-subscription.test.ts +++ b/packages/db/tests/collection-subscription.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' +import { createDeferred } from '../src/deferred.js' +import { Func, PropRef, Value } from '../src/query/ir.js' +import { DeduplicatedLoadSubset } from '../src/query/subset-dedupe.js' import { flushPromises } from './utils' +import type { LoadSubsetOptions } from '../src/types.js' describe(`CollectionSubscription status tracking`, () => { it(`subscription starts with status 'ready'`, () => { @@ -43,7 +47,6 @@ describe(`CollectionSubscription status tracking`, () => { const subscription = collection.subscribeChanges(() => {}, { includeInitialState: false, }) - expect(subscription.status).toBe(`ready`) // Trigger a snapshot request that will call loadSubset @@ -246,6 +249,631 @@ describe(`CollectionSubscription status tracking`, () => { subscription.unsubscribe() }) + it(`records the last rejected subset load without hiding ready data`, async () => { + const error = new Error(`incremental subset failed`) + const collection = createCollection<{ id: string; value: string }>({ + id: `subset-error-recording`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `cached`, value: `available` }, + }) + commit() + markReady() + return { + loadSubset: () => Promise.reject(error), + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + const failures: Array = [] + subscription.on(`loadSubset:error`, (event) => failures.push(event.error)) + + subscription.requestSnapshot({ optimizedOnly: false }) + await flushPromises() + + expect(subscription.status).toBe(`ready`) + expect(collection.get(`cached`)).toMatchObject({ value: `available` }) + expect(subscription.lastError).toBe(error) + expect(failures).toEqual([error]) + + subscription.unsubscribe() + await collection.cleanup() + }) + + it(`records a synchronously thrown subset failure`, async () => { + const error = new Error(`synchronous subset failure`) + const collection = createCollection<{ id: string }>({ + id: `synchronous-subset-error`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw error + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + const failures: Array = [] + subscription.on(`loadSubset:error`, (event) => failures.push(event.error)) + + expect(() => + subscription.requestSnapshot({ optimizedOnly: false }), + ).toThrow(error) + expect(subscription.lastError).toBe(error) + expect(failures).toEqual([error]) + + subscription.unsubscribe() + await collection.cleanup() + }) + + it(`does not unload a subset when loadSubset throws before acquisition`, async () => { + const failure = new Error(`subset failed before acquisition`) + const unloadedOptions: Array = [] + const collection = createCollection<{ id: string }>({ + id: `failed-subset-acquisition`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + unloadSubset: (options) => unloadedOptions.push(options), + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + expect(() => + subscription.requestSnapshot({ optimizedOnly: false }), + ).toThrow(failure) + subscription.unsubscribe() + + expect(unloadedOptions).toEqual([]) + await collection.cleanup() + }) + + it(`releases a subset when its load-result observer throws`, async () => { + const failure = new Error(`load-result observer failed`) + let acquiredOptions: unknown + const unloadedOptions: Array = [] + const collection = createCollection<{ id: string }>({ + id: `subset-observer-failure`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + acquiredOptions = options + return true + }, + unloadSubset: (options) => unloadedOptions.push(options), + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + expect(() => + subscription.requestSnapshot({ + optimizedOnly: false, + onLoadSubsetResult: () => { + throw failure + }, + }), + ).toThrow(failure) + subscription.unsubscribe() + + expect(unloadedOptions).toEqual([acquiredOptions]) + await collection.cleanup() + }) + + it(`reports a rejected subset replay after truncate`, async () => { + const error = new Error(`truncate replay failed`) + let truncateSource: () => void = () => { + throw new Error(`source has not started`) + } + let loadCount = 0 + const collection = createCollection<{ id: string }>({ + id: `truncate-subset-error`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, commit, markReady, truncate }) => { + markReady() + truncateSource = () => { + begin() + truncate() + commit() + } + return { + loadSubset: () => { + loadCount++ + return loadCount === 1 ? Promise.resolve() : Promise.reject(error) + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + const failures: Array = [] + subscription.on(`loadSubset:error`, (event) => failures.push(event.error)) + + subscription.requestSnapshot({ optimizedOnly: false }) + await flushPromises() + truncateSource() + await flushPromises() + + expect(subscription.status).toBe(`ready`) + expect(subscription.lastError).toBe(error) + expect(failures).toEqual([error]) + + subscription.unsubscribe() + await collection.cleanup() + }) + + it(`waits for every logical demand that shares one replay promise`, async () => { + type Row = { id: string; value: number } + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => void + let truncate!: () => void + const replay = createDeferred() + let transportCalls = 0 + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: () => { + transportCalls++ + if (transportCalls === 1) { + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + return Promise.resolve() + } + return replay.promise + }, + }) + const collection = createCollection({ + id: `shared-replay-promise`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: dedupe.loadSubset, + unloadSubset: () => {}, + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else visible.set(change.key, change.value) + } + }, + { includeInitialState: false }, + ) + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) + + try { + subscription.requestSnapshot({ where }) + await flushPromises() + subscription.requestSnapshot({ where }) + expect(transportCalls).toBe(1) + expect( + [...visible.values()].map(({ id, value }) => ({ id, value })), + ).toEqual([{ id: `one`, value: 1 }]) + + dedupe.reset() + begin() + truncate() + commit() + await flushPromises() + expect(transportCalls).toBe(2) + + subscription.releaseSnapshot(where) + const failure = new Error(`shared replay failed`) + replay.reject(failure) + await flushPromises() + + expect(subscription.lastError).toBe(failure) + expect( + [...visible.values()].map(({ id, value }) => ({ id, value })), + ).toEqual([{ id: `one`, value: 1 }]) + } finally { + replay.resolve() + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`keeps the old lease when replacing it fails`, async () => { + const replay = createDeferred() + const loads: Array = [] + const unloads: Array = [] + let truncate!: () => void + let begin!: () => void + let commit!: () => void + const collection = createCollection<{ id: string }>({ + id: `failed-replay-lease-replacement`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return loads.length === 1 ? true : replay.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (options === loads[0] && unloads.length === 1) { + throw new Error(`old lease release failed`) + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + let unsubscribed = false + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + begin() + truncate() + commit() + await flushPromises() + + expect(loads).toHaveLength(2) + expect(subscription.status).toBe(`loadingSubset`) + + replay.reject(new DOMException(`replacement abandoned`, `AbortError`)) + await flushPromises() + expect(subscription.status).toBe(`ready`) + expect(subscription.lastError).toEqual( + new Error(`old lease release failed`), + ) + + subscription.unsubscribe() + unsubscribed = true + expect(unloads).toEqual([loads[0], loads[1], loads[0]]) + } finally { + replay.resolve() + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`retains a subset after a synchronous truncate replay failure`, async () => { + const error = new Error(`synchronous truncate replay failed`) + let truncateSource: () => void = () => { + throw new Error(`source has not started`) + } + let loadCount = 0 + let unloadCount = 0 + const collection = createCollection<{ id: string }>({ + id: `synchronous-truncate-subset-error`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, commit, markReady, truncate }) => { + markReady() + truncateSource = () => { + begin() + truncate() + commit() + } + return { + loadSubset: () => { + loadCount++ + if (loadCount === 2) throw error + return true + }, + unloadSubset: () => { + unloadCount++ + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + subscription.requestSnapshot({ optimizedOnly: false }) + truncateSource() + await flushPromises() + truncateSource() + await flushPromises() + + expect(loadCount).toBe(3) + expect(subscription.lastError).toBe(error) + + subscription.unsubscribe() + // The initial load and the later successful replay each acquired a lease. + expect(unloadCount).toBe(2) + await collection.cleanup() + }) + + it.each([`throw`, `reject`] as const)( + `keeps the last published snapshot when truncate replay fails ($0)`, + async (delivery) => { + type Row = { id: string } + const error = new Error(`truncate replay failed before replacement`) + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + let failReplay = true + const collection = createCollection({ + id: `truncate-replay-preserves-snapshot`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount > 1 && failReplay) { + if (delivery === `throw`) throw error + return Promise.reject(error) + } + begin() + write({ type: `insert`, value: { id: `one` } }) + commit() + return true + }, + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else visible.set(change.key, change.value) + } + }, + { includeInitialState: false }, + ) + + subscription.requestSnapshot({ optimizedOnly: false }) + expect([...visible.keys()]).toEqual([`one`]) + + begin() + truncate() + commit() + await flushPromises() + + expect(subscription.lastError).toBe(error) + expect([...visible.keys()]).toEqual([`one`]) + + begin() + write({ type: `insert`, value: { id: `two` } }) + commit() + await flushPromises() + + expect([...visible.keys()].sort()).toEqual([`one`, `two`]) + + failReplay = false + begin() + truncate() + commit() + await flushPromises() + + expect([...visible.keys()]).toEqual([`one`]) + + subscription.unsubscribe() + await collection.cleanup() + }, + ) + + it(`publishes one coherent snapshot after overlapping truncate replays`, async () => { + type Row = { id: string } + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const resolveReplays: Array<() => void> = [] + const collection = createCollection({ + id: `overlapping-truncate-replays`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `old` } }) + commit() + return true + } + if (loadCount === 3) { + begin() + write({ type: `insert`, value: { id: `new` } }) + commit() + } + return new Promise((resolve) => + resolveReplays.push(resolve), + ) + }, + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else visible.set(change.key, change.value) + } + }, + { includeInitialState: false }, + ) + + subscription.requestSnapshot({ optimizedOnly: false }) + expect([...visible.keys()]).toEqual([`old`]) + + begin() + truncate() + commit() + await flushPromises() + + begin() + truncate() + commit() + await flushPromises() + + resolveReplays[1]!() + await flushPromises() + expect([...visible.keys()]).toEqual([`old`]) + + resolveReplays[0]!() + await flushPromises() + expect([...visible.keys()]).toEqual([`new`]) + + subscription.unsubscribe() + await collection.cleanup() + }) + + it(`scopes a subset failure to the subscription that requested it`, async () => { + const error = new Error(`first subscription failed`) + let loadCount = 0 + const collection = createCollection<{ id: string }>({ + id: `scoped-subset-error`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + loadCount++ + return loadCount === 1 ? Promise.reject(error) : Promise.resolve() + }, + } + }, + }, + }) + const failing = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + const healthy = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + failing.requestSnapshot({ optimizedOnly: false }) + healthy.requestSnapshot({ optimizedOnly: false }) + await flushPromises() + + expect(collection.status).toBe(`ready`) + expect(failing.lastError).toBe(error) + expect(healthy.lastError).toBeUndefined() + + failing.unsubscribe() + healthy.unsubscribe() + await collection.cleanup() + }) + + it(`does not report an aborted subset request as a failure`, async () => { + const cancellation = new Error(`obsolete subset request`) + cancellation.name = `AbortError` + const collection = createCollection<{ id: string }>({ + id: `aborted-subset-request`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: ({ signal }) => + new Promise((_resolve, reject) => { + signal?.addEventListener(`abort`, () => reject(cancellation), { + once: true, + }) + }), + } + }, + }, + }) + const controller = new AbortController() + const failures: Array = [] + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + subscription.on(`loadSubset:error`, (event) => failures.push(event.error)) + + subscription.requestSnapshot({ + optimizedOnly: false, + signal: controller.signal, + }) + controller.abort() + await flushPromises() + + expect(subscription.status).toBe(`ready`) + expect(subscription.lastError).toBeUndefined() + expect(failures).toEqual([]) + + subscription.unsubscribe() + await collection.cleanup() + }) + it(`unsubscribe clears event listeners`, () => { const collection = createCollection<{ id: string; value: string }>({ id: `test`, diff --git a/packages/db/tests/collection-truncate.test.ts b/packages/db/tests/collection-truncate.test.ts index b4f244258..af2977c80 100644 --- a/packages/db/tests/collection-truncate.test.ts +++ b/packages/db/tests/collection-truncate.test.ts @@ -744,6 +744,7 @@ describe(`Collection truncate operations`, () => { | undefined let loadSubsetResolver: (() => void) | undefined let loadSubsetCallCount = 0 + let totalLoadSubsetCallCount = 0 const collection = createCollection<{ id: number; value: string }, number>({ id: `truncate-buffering-test`, @@ -758,6 +759,8 @@ describe(`Collection truncate operations`, () => { return { loadSubset: (_options: LoadSubsetOptions) => { loadSubsetCallCount++ + totalLoadSubsetCallCount++ + const requestNumber = totalLoadSubsetCallCount // Return a promise that we control return new Promise((resolve) => { @@ -766,11 +769,17 @@ describe(`Collection truncate operations`, () => { cfg.begin() cfg.write({ type: `insert`, - value: { id: 1, value: `refetched-1` }, + value: { + id: 1, + value: requestNumber === 1 ? `initial-1` : `refetched-1`, + }, }) cfg.write({ type: `insert`, - value: { id: 2, value: `refetched-2` }, + value: { + id: 2, + value: requestNumber === 1 ? `initial-2` : `refetched-2`, + }, }) cfg.commit() resolve() @@ -804,8 +813,8 @@ describe(`Collection truncate operations`, () => { // Verify initial data arrived expect(stripChanges(changeEvents)).toEqual([ - { type: `insert`, key: 1, value: { id: 1, value: `refetched-1` } }, - { type: `insert`, key: 2, value: { id: 2, value: `refetched-2` } }, + { type: `insert`, key: 1, value: { id: 1, value: `initial-1` } }, + { type: `insert`, key: 2, value: { id: 2, value: `initial-2` } }, ]) // Clear events for next phase @@ -831,15 +840,14 @@ describe(`Collection truncate operations`, () => { // Wait for buffered events to be flushed await vi.waitFor(() => expect(changeEvents.length).toBeGreaterThan(0)) - // Verify we got all events in one batch (deletes + inserts) - // The subscription should have received: - // - Delete events for the old data (from truncate) - // - Insert events for the new data (from refetch) + // The raw truncate/refetch stream is reduced to one semantic replacement. const deletes = changeEvents.filter((e) => e.type === `delete`) const inserts = changeEvents.filter((e) => e.type === `insert`) + const updates = changeEvents.filter((e) => e.type === `update`) - expect(deletes.length).toBe(2) // Deleted the old items - expect(inserts.length).toBe(2) // Inserted the refetched items + expect(deletes).toHaveLength(0) + expect(inserts).toHaveLength(0) + expect(updates).toHaveLength(2) // Verify final state is correct expect(collection.state.size).toBe(2) @@ -988,6 +996,7 @@ describe(`Collection truncate operations`, () => { | undefined let loadSubsetResolver: (() => void) | undefined let loadSubsetCallCount = 0 + let totalLoadSubsetCallCount = 0 const collection = createCollection<{ id: number; value: string }, number>({ id: `truncate-loadedInitialState-test`, @@ -1002,17 +1011,25 @@ describe(`Collection truncate operations`, () => { return { loadSubset: (_options: LoadSubsetOptions) => { loadSubsetCallCount++ + totalLoadSubsetCallCount++ + const requestNumber = totalLoadSubsetCallCount return new Promise((resolve) => { loadSubsetResolver = () => { cfg.begin() cfg.write({ type: `insert`, - value: { id: 1, value: `item-1` }, + value: { + id: 1, + value: requestNumber === 1 ? `item-1` : `refetched-1`, + }, }) cfg.write({ type: `insert`, - value: { id: 2, value: `item-2` }, + value: { + id: 2, + value: requestNumber === 1 ? `item-2` : `refetched-2`, + }, }) cfg.commit() resolve() @@ -1065,10 +1082,11 @@ describe(`Collection truncate operations`, () => { // Wait for events to be emitted await vi.waitFor(() => expect(changeEvents.length).toBeGreaterThan(0)) - // The key assertion: we should have received delete events - // Without the fix, sentKeys would be empty and deletes would be filtered out - const deletes = changeEvents.filter((e) => e.type === `delete`) - expect(deletes.length).toBe(2) // Must have delete events! + // Even with loadedInitialState, the replacement must publish the exact + // semantic changes rather than filtering the truncate stream away. + expect( + changeEvents.filter((event) => event.type === `update`), + ).toHaveLength(2) subscription.unsubscribe() }) @@ -1083,6 +1101,7 @@ describe(`Collection truncate operations`, () => { let syncOps: | Parameters[`sync`]>[0] | undefined + let loadSubsetCallCount = 0 const collection = createCollection<{ id: number; value: string }, number>({ id: `truncate-sync-loadSubset-test`, @@ -1097,15 +1116,17 @@ describe(`Collection truncate operations`, () => { return { // loadSubset returns true (synchronous) - data already available loadSubset: (_options: LoadSubsetOptions) => { + loadSubsetCallCount++ + const prefix = loadSubsetCallCount === 1 ? `sync` : `refetched` // Synchronously write data cfg.begin() cfg.write({ type: `insert`, - value: { id: 1, value: `sync-item-1` }, + value: { id: 1, value: `${prefix}-item-1` }, }) cfg.write({ type: `insert`, - value: { id: 2, value: `sync-item-2` }, + value: { id: 2, value: `${prefix}-item-2` }, }) cfg.commit() return true // Synchronous return @@ -1139,28 +1160,24 @@ describe(`Collection truncate operations`, () => { // Wait for events to settle await vi.advanceTimersByTimeAsync(10) - // We should have received delete events even though loadSubset was sync + // The synchronous replay publishes one semantic replacement. const deletes = changeEvents.filter((e) => e.type === `delete`) const inserts = changeEvents.filter((e) => e.type === `insert`) + const updates = changeEvents.filter((e) => e.type === `update`) - expect(deletes.length).toBe(2) // Should have 2 deletes - expect(inserts.length).toBe(2) // Should have 2 inserts - - // Verify correct ordering: deletes should come before inserts - // (truncate clears old data, then refetch adds new data) - const firstDeleteIdx = changeEvents.findIndex((e) => e.type === `delete`) - const firstInsertIdx = changeEvents.findIndex((e) => e.type === `insert`) - expect(firstDeleteIdx).toBeLessThan(firstInsertIdx) + expect(deletes).toHaveLength(0) + expect(inserts).toHaveLength(0) + expect(updates).toHaveLength(2) // Verify collection state is correct expect(collection.state.size).toBe(2) expect(getStateValue(collection, 1)).toEqual({ id: 1, - value: `sync-item-1`, + value: `refetched-item-1`, }) expect(getStateValue(collection, 2)).toEqual({ id: 2, - value: `sync-item-2`, + value: `refetched-item-2`, }) subscription.unsubscribe() diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index e96994db6..3ff8ede81 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -2182,6 +2182,45 @@ describe(`Collection isLoadingSubset property`, () => { expect(collection.isLoadingSubset).toBe(false) }) + it(`cleanup isolates subset loading state from a later sync session`, async () => { + const resolveLoads: Array<() => void> = [] + const collection = createCollection<{ id: string; value: string }>({ + id: `cleanup-isolates-subset-loading`, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => + new Promise((resolve) => resolveLoads.push(resolve)), + } + }, + }, + }) + + collection._sync.loadSubset({}) + expect(collection.isLoadingSubset).toBe(true) + + await collection.cleanup() + expect(collection.isLoadingSubset).toBe(false) + + collection.startSyncImmediate() + collection._sync.loadSubset({}) + expect(collection.isLoadingSubset).toBe(true) + + resolveLoads[0]!() + await flushPromises() + expect(collection.isLoadingSubset).toBe(true) + + resolveLoads[1]!() + await flushPromises() + expect(collection.isLoadingSubset).toBe(false) + + await collection.cleanup() + }) + it(`emits loadingSubset:change event`, async () => { let resolveLoadSubset: () => void const loadSubsetPromise = new Promise((resolve) => { diff --git a/packages/db/tests/effect.test.ts b/packages/db/tests/effect.test.ts index 154b35382..5c95625a4 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -6,7 +6,10 @@ import { mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, } from './utils.js' -import type { DeltaEvent } from '../src/index.js' +import type { + DeltaEvent, + SubscriptionLoadSubsetErrorEvent, +} from '../src/index.js' // --------------------------------------------------------------------------- // Test types and helpers @@ -645,6 +648,74 @@ describe(`createEffect`, () => { await effect.dispose() // Should not throw expect(effect.disposed).toBe(true) }) + + it(`joins cleanup that is already in progress`, async () => { + const users = createUsersCollection([sampleUsers[0]!]) + let resolveHandler!: () => void + const handlerPending = new Promise((resolve) => { + resolveHandler = resolve + }) + const effect = createEffect({ + query: (q) => q.from({ user: users }), + onEnter: () => handlerPending, + }) + + await flushPromises() + const firstDispose = effect.dispose() + let secondDisposeSettled = false + const secondDispose = effect.dispose().finally(() => { + secondDisposeSettled = true + }) + + await Promise.resolve() + expect(secondDisposeSettled).toBe(false) + + resolveHandler() + await Promise.all([firstDispose, secondDispose]) + expect(secondDisposeSettled).toBe(true) + }) + + it(`reports one in-progress cleanup failure to every disposer`, async () => { + const failure = new Error(`source release failed`) + let resolveHandler!: () => void + const handlerPending = new Promise((resolve) => { + resolveHandler = resolve + }) + const source = createCollection<{ id: number }>({ + id: `joined-effect-cleanup-error`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + begin() + write({ type: `insert`, value: { id: 1 } }) + commit() + return true + }, + unloadSubset: () => { + throw failure + }, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => q.from({ source }), + onEnter: () => handlerPending, + }) + + await flushPromises() + const firstDispose = effect.dispose() + const secondDispose = effect.dispose() + resolveHandler() + + await expect(firstDispose).rejects.toBe(failure) + await expect(secondDispose).rejects.toBe(failure) + await source.cleanup() + }) }) describe(`auto-generated IDs`, () => { @@ -1507,6 +1578,455 @@ describe(`createEffect`, () => { }) describe(`source error handling`, () => { + it(`does not subscribe later sources after startup disposes the effect`, async () => { + const failure = new Error(`synchronous source failure`) + const users = createUsersCollection([sampleUsers[0]!]) + const issues = createIssuesCollection([sampleIssues[0]!]) + const subscribeChanges = users.subscribeChanges.bind(users) + + vi.spyOn(users, `subscribeChanges`).mockImplementation((( + callback, + options, + ) => { + const subscription = subscribeChanges(callback, { + ...options, + includeInitialState: false, + }) + const errorEvent: SubscriptionLoadSubsetErrorEvent = { + type: `loadSubset:error`, + subscription, + options: { subscription }, + error: failure, + } + options?.onLoadSubsetError?.(errorEvent) + return subscription + }) as typeof users.subscribeChanges) + + const sourceErrors: Array = [] + const effect = createEffect({ + query: (q) => + q + .from({ user: users }) + .leftJoin({ issue: issues }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .select(({ user, issue }) => ({ + id: user.id, + issueId: issue.id, + })), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }) + + expect(sourceErrors).toEqual([failure]) + expect(effect.disposed).toBe(true) + expect(users.subscriberCount).toBe(0) + expect(issues.subscriberCount).toBe(0) + await Promise.all([users.cleanup(), issues.cleanup()]) + }) + + it(`releases every source when one unsubscriber throws`, async () => { + const failure = new Error(`first source unload failed`) + const createSource = (id: string, unloadSubset: () => void) => + createCollection<{ id: number }>({ + id, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => true, + unloadSubset, + } + }, + }, + }) + const left = createSource(`effect-cleanup-left`, () => { + throw failure + }) + const right = createSource(`effect-cleanup-right`, () => {}) + const effect = createEffect({ + query: (q) => + q + .from({ left }) + .leftJoin({ right }, ({ left: leftRow, right: rightRow }) => + eq(leftRow.id, rightRow.id), + ), + onBatch: () => {}, + }) + + expect(left.subscriberCount).toBe(1) + expect(right.subscriberCount).toBe(1) + + await expect(effect.dispose()).rejects.toBe(failure) + expect(left.subscriberCount).toBe(0) + expect(right.subscriberCount).toBe(0) + + await Promise.all([left.cleanup(), right.cleanup()]) + }) + + it(`preserves a startup error when cleanup also fails`, async () => { + const startupFailure = new Error(`second source failed to subscribe`) + const cleanupFailure = new Error(`first source failed to unload`) + const left = createCollection<{ id: number }>({ + id: `effect-startup-error-left`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => true, + unloadSubset: () => { + throw cleanupFailure + }, + } + }, + }, + }) + const right = createCollection<{ id: number }>({ + id: `effect-startup-error-right`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset: () => true } + }, + }, + }) + vi.spyOn(right, `subscribeChanges`).mockImplementation(() => { + throw startupFailure + }) + const consoleErrorSpy = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + + try { + expect(() => + createEffect({ + query: (q) => + q + .from({ left }) + .leftJoin({ right }, ({ left: leftRow, right: rightRow }) => + eq(leftRow.id, rightRow.id), + ), + onBatch: () => {}, + }), + ).toThrow(startupFailure) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`failed to dispose after a startup error`), + cleanupFailure, + ) + expect(left.subscriberCount).toBe(0) + expect(right.subscriberCount).toBe(0) + } finally { + consoleErrorSpy.mockRestore() + await Promise.all([left.cleanup(), right.cleanup()]) + } + }) + + it(`releases source ownership when the automatic subset load throws`, async () => { + const failure = new Error(`automatic subset failed`) + const users = createCollection({ + id: `effect-synchronous-subset-error`, + getKey: (user) => user.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const sourceErrors: Array = [] + + expect(() => + createEffect({ + query: (q) => q.from({ user: users }), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }), + ).toThrow(failure) + + expect(sourceErrors).toEqual([failure]) + expect(users.subscriberCount).toBe(0) + await users.cleanup() + }) + + it(`releases an ordered source when its initial subset load throws`, async () => { + const failure = new Error(`initial ordered subset failed`) + const users = createCollection({ + id: `effect-initial-ordered-subset-error`, + getKey: (user) => user.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const sourceErrors: Array = [] + + expect(() => + createEffect({ + query: (q) => + q + .from({ user: users }) + .orderBy(({ user }) => user.name, `asc`) + .limit(1), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }), + ).toThrow(failure) + + expect(sourceErrors).toEqual([failure]) + expect(users.subscriberCount).toBe(0) + await users.cleanup() + }) + + it(`releases every source when initial lazy demand throws`, async () => { + const failure = new Error(`initial lazy demand failed`) + const users = createUsersCollection([sampleUsers[0]!]) + const issues = createCollection({ + id: `effect-initial-lazy-subset-error`, + getKey: (issue) => issue.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const sourceErrors: Array = [] + + expect(() => + createEffect({ + query: (q) => + q + .from({ user: users }) + .leftJoin({ issue: issues }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .select(({ user, issue }) => ({ + id: user.id, + issueId: issue.id, + })), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }), + ).toThrow(failure) + + expect(sourceErrors).toEqual([failure]) + expect(users.subscriberCount).toBe(0) + expect(issues.subscriberCount).toBe(0) + await Promise.all([users.cleanup(), issues.cleanup()]) + }) + + it(`isolates synchronous lazy-demand failure from an established source commit`, async () => { + const failure = new Error(`incremental effect lazy demand failed`) + const users = createCollection( + mockSyncCollectionOptions({ + id: `incremental-effect-users`, + getKey: (user) => user.id, + initialData: [], + }), + ) + const issues = createCollection({ + id: `incremental-effect-issues`, + getKey: (issue) => issue.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const sourceErrors: Array = [] + const effect = createEffect({ + query: (q) => + q + .from({ user: users }) + .leftJoin({ issue: issues }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .select(({ user, issue }) => ({ + id: user.id, + issueId: issue.id, + })), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }) + + try { + let commitError: unknown + try { + users.utils.begin() + users.utils.write({ type: `insert`, value: sampleUsers[0]! }) + users.utils.commit() + } catch (error) { + commitError = error + } + + expect(commitError).toBeUndefined() + expect(sourceErrors).toEqual([failure]) + expect(effect.disposed).toBe(true) + } finally { + await effect.dispose() + await Promise.all([users.cleanup(), issues.cleanup()]) + } + }) + + it(`reports a rejected ordered subset load and disposes the effect`, async () => { + const failure = new Error(`ordered subset failed`) + let loadCount = 0 + let removeVisibleRow: () => void = () => { + throw new Error(`source has not started`) + } + const users = createCollection({ + id: `effect-rejected-ordered-users`, + getKey: (user) => user.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + removeVisibleRow = () => { + begin() + write({ type: `delete`, value: sampleUsers[0]! }) + commit() + } + return { + loadSubset: () => { + loadCount++ + if (loadCount > 1) return Promise.reject(failure) + begin() + write({ type: `insert`, value: sampleUsers[0]! }) + commit() + return Promise.resolve() + }, + } + }, + }, + }) + const sourceErrors: Array = [] + const effect = createEffect({ + query: (q) => + q + .from({ user: users }) + .orderBy(({ user }) => user.name, `asc`) + .limit(1), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }) + + try { + await flushPromises() + expect(sourceErrors).toEqual([]) + + removeVisibleRow() + await flushPromises() + + expect(sourceErrors).toEqual([failure]) + expect(effect.disposed).toBe(true) + } finally { + await effect.dispose() + await users.cleanup() + } + }) + + it(`reports a cleanup failure from automatic disposal`, async () => { + const loadFailure = new Error(`ordered subset failed`) + const cleanupFailure = new Error(`ordered subset cleanup failed`) + let loadCount = 0 + let removeVisibleRow: () => void = () => { + throw new Error(`source has not started`) + } + const users = createCollection({ + id: `effect-rejected-ordered-cleanup-users`, + getKey: (user) => user.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + removeVisibleRow = () => { + begin() + write({ type: `delete`, value: sampleUsers[0]! }) + commit() + } + return { + loadSubset: () => { + loadCount++ + if (loadCount > 1) return Promise.reject(loadFailure) + begin() + write({ type: `insert`, value: sampleUsers[0]! }) + commit() + return Promise.resolve() + }, + unloadSubset: () => { + throw cleanupFailure + }, + } + }, + }, + }) + const sourceErrors: Array = [] + const consoleErrorSpy = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + const effect = createEffect({ + query: (q) => + q + .from({ user: users }) + .orderBy(({ user }) => user.name, `asc`) + .limit(1), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }) + + try { + await flushPromises() + removeVisibleRow() + await flushPromises() + + expect(sourceErrors).toEqual([loadFailure]) + expect(effect.disposed).toBe(true) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`failed to dispose after a source error`), + cleanupFailure, + ) + } finally { + await expect(effect.dispose()).rejects.toBe(cleanupFailure) + consoleErrorSpy.mockRestore() + await users.cleanup() + } + }) + it(`reports a rejected lazy subset load and disposes the effect`, async () => { const users = createUsersCollection([sampleUsers[0]!]) const issues = createCollection({ @@ -1549,6 +2069,69 @@ describe(`createEffect`, () => { } }) + it(`keeps the effect alive when obsolete lazy demand is aborted`, async () => { + const users = createUsersCollection([sampleUsers[0]!]) + const cancellation = new Error(`obsolete lazy demand`) + cancellation.name = `AbortError` + let capturedSignal: AbortSignal | undefined + const issues = createCollection({ + id: `effect-aborted-lazy-issues`, + getKey: (issue) => issue.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: ({ signal }) => { + capturedSignal = signal + return new Promise((_resolve, reject) => { + signal?.addEventListener( + `abort`, + () => reject(cancellation), + { once: true }, + ) + }) + }, + } + }, + }, + }) + const sourceErrors: Array = [] + const effect = createEffect({ + query: (q) => + q + .from({ user: users }) + .leftJoin({ issue: issues }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .select(({ user, issue }) => ({ + id: user.id, + issueId: issue.id, + })), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }) + + try { + await flushPromises() + expect(capturedSignal?.aborted).toBe(false) + + users.utils.begin() + users.utils.write({ type: `delete`, value: sampleUsers[0]! }) + users.utils.commit() + await flushPromises() + + expect(capturedSignal?.aborted).toBe(true) + expect(sourceErrors).toEqual([]) + expect(effect.disposed).toBe(false) + } finally { + await effect.dispose() + await Promise.all([users.cleanup(), issues.cleanup()]) + } + }) + it(`should auto-dispose when source collection is cleaned up`, async () => { const users = createUsersCollection() const events: Array> = [] diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 1bca17d3a..9ecd44d1e 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -476,7 +476,20 @@ describe(`createLiveQueryWindowController`, () => { }, }, }) - const lq = makeOrderedLiveQuery(source, 2) + const lq = createLiveQueryCollection({ + query: (q) => + q + .from({ r: source }) + .orderBy(({ r }) => r.n, `asc`) + .limit(3) + .offset(0) + .select(({ r }) => ({ id: r.id, n: r.n })), + startSync: true, + gcTime: 1, + utils: { + customUtility: () => `custom`, + }, + }) const controller = createLiveQueryWindowController(lq as any, { pageSize: 2, }) @@ -488,6 +501,8 @@ describe(`createLiveQueryWindowController`, () => { await expect(controller.fetchNextPage()).rejects.toBe(failure) expect(controller.getSnapshot().pages).toHaveLength(1) expect(controller.getSnapshot().error).toBe(failure) + expect(lq.utils.lastSubsetError).toBe(failure) + expect(lq.utils.customUtility()).toBe(`custom`) controller.dispose() }) @@ -524,6 +539,124 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`reset does not inherit a superseded expansion failure`, async () => { + const failure = new Error(`superseded expansion failed`) + let loadCount = 0 + const rejectLoads = new Map void>() + const loaded = new Set() + const source = createCollection({ + id: `window-reset-real-source-${seq++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options) => { + loadCount++ + if (loadCount === 2) { + return new Promise((_resolve, reject) => { + rejectLoads.set(loadCount, reject) + }) + } + begin() + ROWS.slice(0, options.limit).forEach((row) => { + if (loaded.has(row.id)) return + loaded.add(row.id) + write({ type: `insert`, value: row }) + }) + commit() + return Promise.resolve() + }, + } + }, + }, + }) + const lq = makeOrderedLiveQuery(source, 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + + try { + await controller.preload() + const expansion = Promise.resolve(controller.fetchNextPage()) + expect(loadCount).toBe(2) + const rejectExpansion = rejectLoads.get(2) + expect(rejectExpansion).toBeDefined() + const reset = Promise.resolve(controller.reset()) + void expansion.catch(() => undefined) + void reset.catch(() => undefined) + + rejectExpansion!(failure) + + await expect(reset).resolves.toBeUndefined() + await expect(expansion).rejects.toBe(failure) + expect(controller.getSnapshot().pages).toHaveLength(1) + } finally { + controller.dispose() + await Promise.all([lq.cleanup(), source.cleanup()]) + } + }) + + it(`cleanup settles the active load operation before another sync session`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + await lq.preload() + + let resolveLoad!: () => void + const load = new Promise((resolve) => { + resolveLoad = resolve + }) + const operation = lq._sync.beginLoadSubsetOperation() + lq._sync.trackLoadPromise(load) + const waiting = Promise.resolve(operation.wait()) + let settled = false + void waiting.then(() => { + settled = true + }) + + lq._sync.cleanup() + await Promise.resolve() + + expect(settled).toBe(true) + + resolveLoad() + await waiting + await lq.cleanup() + }) + + it(`cleanup settles every superseded load operation`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + await lq.preload() + + const firstLoad = new Promise(() => {}) + const firstOperation = lq._sync.beginLoadSubsetOperation() + lq._sync.trackLoadPromise(firstLoad) + const firstWaiting = Promise.resolve(firstOperation.wait()) + + const secondLoad = new Promise(() => {}) + const secondOperation = lq._sync.beginLoadSubsetOperation() + lq._sync.trackLoadPromise(secondLoad) + const secondWaiting = Promise.resolve(secondOperation.wait()) + const settled = [false, false] + void firstWaiting.then(() => { + settled[0] = true + }) + void secondWaiting.then(() => { + settled[1] = true + }) + + lq._sync.cleanup() + await Promise.resolve() + + expect(settled).toEqual([true, true]) + await Promise.all([firstWaiting, secondWaiting]) + await lq.cleanup() + }) + it(`retains an unsubscribed lease until overlapping requests settle`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) await lq.preload() diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 2fc9eebcb..649dccd49 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -2,7 +2,6 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' -import { CollectionIsInErrorStateError } from '../../src/errors.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { extractSimpleComparisons } from '../../src/query/expression-helpers.js' import { SubsetDemandController } from '../../src/query/live/subset-demand-controller.js' @@ -224,7 +223,7 @@ function createReadinessDriver( start: async (context) => { const preload = startPreload(context.live, context.preload) await context.parentLoaded.promise - if (initialPosts.length > 0) await preload + await preload }, apply: () => undefined, cleanup: async ({ posts, comments, live, preload }) => { @@ -742,6 +741,7 @@ async function expectRejectedDemandEntersError(): Promise { ]) let loadCount = 0 let shouldReject = true + const childLoadError = new Error(`child load failed`) const comments = createCollection({ id: nextCollectionId(`temporal-rejected-comments`), getKey: (comment) => comment.id, @@ -751,7 +751,7 @@ async function expectRejectedDemandEntersError(): Promise { loadSubset: () => { loadCount += 1 if (shouldReject) { - return Promise.reject(new Error(`child load failed`)) + return Promise.reject(childLoadError) } markReady() return true @@ -769,9 +769,7 @@ async function expectRejectedDemandEntersError(): Promise { expect(loadCount).toBe(1) expect(live.status).toBe(`error`) expect(preload.preloadSettled).toBe(true) - expect(preload.preloadFailure?.error).toBeInstanceOf( - CollectionIsInErrorStateError, - ) + expect(preload.preloadFailure?.error).toBe(childLoadError) await live.cleanup() await preload.preloadOutcome diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 85478e95c..c6fc4be39 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -1435,6 +1435,300 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.isLoadingSubset).toBe(false) }) + it(`releases an ordered source when initial live-query loading throws`, async () => { + const failure = new Error(`initial ordered live-query load failed`) + const source = createCollection({ + id: `initial-ordered-live-query-error`, + getKey: (user) => user.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ user: source }) + .orderBy(({ user }) => user.name, `asc`) + .limit(1), + ) + + await expect(Promise.resolve().then(() => live.preload())).rejects.toBe( + failure, + ) + expect(source.subscriberCount).toBe(0) + + await Promise.all([live.cleanup(), source.cleanup()]) + }) + + it(`releases earlier live-query sources when initial lazy demand throws`, async () => { + type Issue = { id: number; userId: number } + const failure = new Error(`initial live-query lazy demand failed`) + const users = createCollection( + mockSyncCollectionOptions({ + id: `partial-live-query-users`, + getKey: (user) => user.id, + initialData: [sampleUsers[0]!], + }), + ) + const issues = createCollection({ + id: `partial-live-query-issues`, + getKey: (issue) => issue.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ user: users }) + .leftJoin({ issue: issues }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .select(({ user, issue }) => ({ + id: user.id, + issueId: issue.id, + })), + ) + + await expect(Promise.resolve().then(() => live.preload())).rejects.toBe( + failure, + ) + expect(users.subscriberCount).toBe(0) + expect(issues.subscriberCount).toBe(0) + + await Promise.all([live.cleanup(), users.cleanup(), issues.cleanup()]) + }) + + it(`isolates synchronous lazy-demand failure from an established source commit`, async () => { + type Issue = { id: number; userId: number } + const failure = new Error(`incremental live-query lazy demand failed`) + const users = createCollection( + mockSyncCollectionOptions({ + id: `incremental-live-query-users`, + getKey: (user) => user.id, + initialData: [], + }), + ) + const issues = createCollection({ + id: `incremental-live-query-issues`, + getKey: (issue) => issue.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + throw failure + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ user: users }) + .leftJoin({ issue: issues }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .select(({ user, issue }) => ({ + id: user.id, + issueId: issue.id, + })), + ) + + try { + await live.preload() + expect(live.status).toBe(`ready`) + + let commitError: unknown + try { + users.utils.begin() + users.utils.write({ type: `insert`, value: sampleUsers[0]! }) + users.utils.commit() + } catch (error) { + commitError = error + } + + expect(commitError).toBeUndefined() + expect(live.status).toBe(`error`) + expect(live.utils.lastSubsetError).toBe(failure) + } finally { + await Promise.all([live.cleanup(), users.cleanup(), issues.cleanup()]) + } + }) + + it.each([`throw`, `reject`] as const)( + `propagates lazy child demand failure from a window change ($0)`, + async (delivery) => { + type Parent = { id: number; rank: number } + type Child = { id: number; parentId: number } + const failure = new Error(`window child demand failed`) + const loadedParents = new Set() + let parentLoadCount = 0 + const parents = createCollection({ + id: `window-lazy-demand-parents`, + getKey: (parent) => parent.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + parentLoadCount++ + begin() + const candidates: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + ] + candidates.slice(0, parentLoadCount).forEach((parent) => { + if (loadedParents.has(parent.id)) return + loadedParents.add(parent.id) + write({ type: `insert`, value: parent }) + }) + commit() + return Promise.resolve() + }, + } + }, + }, + }) + let childLoadCount = 0 + const children = createCollection({ + id: `window-lazy-demand-children`, + getKey: (child) => child.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + childLoadCount++ + if (childLoadCount > 1) { + if (delivery === `throw`) throw failure + return Promise.reject(failure) + } + begin() + write({ type: `insert`, value: { id: 10, parentId: 1 } }) + commit() + return Promise.resolve() + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ parent: parents }) + .leftJoin({ child: children }, ({ parent, child }) => + eq(parent.id, child.parentId), + ) + .orderBy(({ parent }) => parent.rank, `asc`) + .limit(1) + .select(({ parent, child }) => ({ + id: parent.id, + childId: child.id, + })), + ) + + try { + await live.preload() + expect(live.status).toBe(`ready`) + + const setWindow = async () => { + const result = live.utils.setWindow({ offset: 0, limit: 2 }) + if (result !== true) await result + } + await expect(setWindow()).rejects.toBe(failure) + expect(live.utils.lastSubsetError).toBe(failure) + } finally { + await Promise.all([ + live.cleanup(), + parents.cleanup(), + children.cleanup(), + ]) + } + }, + ) + + it(`retries the same ordered refill after a transient rejection`, async () => { + type Row = { id: number; rank: number } + const failure = new Error(`ordered refill failed`) + let loadCount = 0 + const source = createCollection({ + id: `ordered-refill-retry-source`, + getKey: (row) => row.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount === 2) return Promise.reject(failure) + const deliver = () => { + begin() + write({ + type: `insert`, + value: { id: loadCount, rank: loadCount }, + }) + commit() + } + if (loadCount === 1) { + deliver() + return true + } + return Promise.resolve().then(deliver) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(2), + ) + + try { + await live.preload() + await flushPromises() + expect(loadCount).toBe(2) + expect(live.utils.lastSubsetError).toBe(failure) + + const retry = live.utils.setWindow({ offset: 0, limit: 2 }) + if (retry instanceof Promise) await retry + await flushPromises() + + expect(loadCount).toBe(3) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 3]) + } finally { + await Promise.all([live.cleanup(), source.cleanup()]) + } + }) + it(`concurrent live queries should each track loading state independently`, async () => { // This tests the fix for the !wasLoadingBefore bug: // When multiple live queries subscribe to the same source collection, @@ -2054,6 +2348,37 @@ describe(`createLiveQueryCollection`, () => { expect(result).toBe(true) }) + it(`does not wait for subset work that predates the window operation`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `window-with-unrelated-load`, + getKey: (user) => user.id, + initialData: sampleUsers, + autoIndex: `eager`, + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ user: source }) + .orderBy(({ user }) => user.name, `asc`) + .limit(1), + ) + let resolveUnrelated: () => void + const unrelated = new Promise((resolve) => { + resolveUnrelated = resolve + }) + + try { + await live.preload() + live._sync.trackLoadPromise(unrelated) + + expect(live.utils.setWindow({ offset: 0, limit: 2 })).toBe(true) + } finally { + resolveUnrelated!() + await Promise.all([live.cleanup(), source.cleanup()]) + } + }) + it(`setWindow returns and resolves a Promise when async loading is triggered`, async () => { // This is an integration test that validates the full async flow: // 1. setWindow triggers loading more data diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 0c17368f2..7b878a5f4 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -1298,6 +1298,10 @@ async function runPendingMutationScenario( )[0]! const pending: Array = [] const deliveredIds = new Set([firstDelivered.id]) + // A rejected initial subset load is fatal. Establish a ready baseline first + // so reject scenarios exercise subscription-scoped window recovery. + let establishInitialCoverageSynchronously = + scenario.responseOutcome === `reject` let begin!: () => void let write!: (message: { type: `insert` | `update` | `delete` @@ -1323,6 +1327,10 @@ async function runPendingMutationScenario( params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { + if (establishInitialCoverageSynchronously) { + establishInitialCoverageSynchronously = false + return true + } const deferred = createDeferred() pending.push({ options, deferred }) return deferred.promise @@ -1336,7 +1344,7 @@ async function runPendingMutationScenario( .from({ row: source }) .orderBy(({ row }) => row.rank, scenario.direction) .orderBy(({ row }) => row.id, `asc`) - .limit(scenario.limit), + .limit(scenario.responseOutcome === `reject` ? 1 : scenario.limit), ) const outstanding: Array> = [] @@ -1384,11 +1392,10 @@ async function runPendingMutationScenario( try { const preload = live.preload() outstanding.push(preload) - expect(pending).toHaveLength(1) - - if (timing === `before-response`) applyMutation() let finalLimit = scenario.limit if (scenario.responseOutcome === `resolve`) { + expect(pending).toHaveLength(1) + if (timing === `before-response`) applyMutation() await settlePending() await preload if (timing === `after-response`) { @@ -1397,13 +1404,29 @@ async function runPendingMutationScenario( await settlePending() } } else { + await preload + expect(pending).toHaveLength(0) + finalLimit += 1 + const failedWindow = live.utils.setWindow({ + offset: 0, + limit: finalLimit, + }) + expect(failedWindow).toBeInstanceOf(Promise) + const cursorError = new Error(`cursor failed`) + const observedFailure = (failedWindow as Promise).then( + () => undefined, + (error: unknown) => error, + ) + outstanding.push((failedWindow as Promise).catch(() => {})) + expect(pending).toHaveLength(1) + if (timing === `before-response`) applyMutation() pending[0]!.settled = true - pending[0]!.deferred.reject(new Error(`cursor failed`)) - await Promise.resolve() - await Promise.allSettled([preload]) + pending[0]!.deferred.reject(cursorError) if (timing === `after-response`) applyMutation() + await flushPromises() + await settlePending() + expect(await observedFailure).toBe(cursorError) - finalLimit += 1 const retry = live.utils.setWindow({ offset: 0, limit: finalLimit }) let retrySettled = retry === true const observedRetry = @@ -1559,6 +1582,9 @@ async function runRejectedCursorRetryAfterMutation(): Promise { ]) const pending: Array = [] const deliveredIds = new Set([1]) + // Keep the rejected cursor in the incremental path rather than failing the + // live query's initial preload. + let establishInitialCoverageSynchronously = true let begin!: () => void let write!: (message: { type: `insert` | `update`; value: PageRow }) => void let commit!: () => void @@ -1580,6 +1606,10 @@ async function runRejectedCursorRetryAfterMutation(): Promise { params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { + if (establishInitialCoverageSynchronously) { + establishInitialCoverageSynchronously = false + return true + } const deferred = createDeferred() pending.push({ options, deferred }) return deferred.promise @@ -1593,7 +1623,7 @@ async function runRejectedCursorRetryAfterMutation(): Promise { .from({ row: source }) .orderBy(({ row }) => row.rank, `asc`) .orderBy(({ row }) => row.id, `asc`) - .limit(2), + .limit(1), ) const settle = async (request: PendingCursorLoad): Promise => { @@ -1614,7 +1644,16 @@ async function runRejectedCursorRetryAfterMutation(): Promise { } try { - const preload = live.preload() + await live.preload() + expect(pending).toHaveLength(0) + + const failedWindow = live.utils.setWindow({ offset: 0, limit: 2 }) + expect(failedWindow).toBeInstanceOf(Promise) + const cursorError = new Error(`cursor failed`) + const observedFailure = (failedWindow as Promise).then( + () => undefined, + (error: unknown) => error, + ) expect(pending).toHaveLength(1) rows.set(1, { id: 1, rank: 3 }) @@ -1623,13 +1662,17 @@ async function runRejectedCursorRetryAfterMutation(): Promise { commit() pending[0]!.settled = true - pending[0]!.deferred.reject(new Error(`cursor failed`)) - await preload - await Promise.resolve() + pending[0]!.deferred.reject(cursorError) + await flushPromises() + for (let index = 1; index < pending.length; index++) { + if (!pending[index]!.settled) await settle(pending[index]!) + } + expect(await observedFailure).toBe(cursorError) const retry = live.utils.setWindow({ offset: 0, limit: 3 }) - expect(pending).toHaveLength(2) - await settle(pending[1]!) + for (let index = 1; index < pending.length; index++) { + if (!pending[index]!.settled) await settle(pending[index]!) + } if (retry instanceof Promise) await retry try { diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts new file mode 100644 index 000000000..c161f2465 --- /dev/null +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { BTreeIndex } from '../../src/indexes/btree-index.js' +import { createEffect, createLiveQueryCollection, eq } from '../../src/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +type Delivery = `throw` | `reject` +type Consumer = `effect` | `live` +type StartupPath = `direct` | `ordered` | `lazy` +type IncrementalPath = Exclude + +type Row = { + id: number + rank: number + parentId: number +} + +type FailureCase = { + name: string + consumer: Consumer + path: TPath + delivery: Delivery +} + +const row: Row = { id: 1, rank: 1, parentId: 1 } + +// Every query form can fail while it acquires initial coverage. +const startupCases: ReadonlyArray> = ( + [`effect`, `live`] as const +).flatMap((consumer) => + ([`direct`, `ordered`, `lazy`] as const).flatMap((path) => + ([`throw`, `reject`] as const).map((delivery) => ({ + name: `${consumer} ${path} ${delivery}`, + consumer, + path, + delivery, + })), + ), +) + +// Direct queries have no automatic later demand. Ordered refills and lazy +// relationship routes do, so only those paths have incremental cells. +const incrementalCases: ReadonlyArray> = ( + [`effect`, `live`] as const +).flatMap((consumer) => + ([`ordered`, `lazy`] as const).flatMap((path) => + ([`throw`, `reject`] as const).map((delivery) => ({ + name: `${consumer} ${path} ${delivery}`, + consumer, + path, + delivery, + })), + ), +) + +function fail(delivery: Delivery, error: Error): Promise { + if (delivery === `throw`) throw error + return Promise.reject(error) +} + +function createFailingSource(id: string, delivery: Delivery, error: Error) { + return createCollection({ + id, + getKey: (item) => item.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => fail(delivery, error), + } + }, + }, + }) +} + +function createStaticSource(id: string, initialData: ReadonlyArray) { + return createCollection( + mockSyncCollectionOptions({ + id, + getKey: (item) => item.id, + initialData: [...initialData], + }), + ) +} + +type RowCollection = ReturnType + +function startEffect( + path: StartupPath, + primary: RowCollection, + child: RowCollection, + sourceErrors: Array, +) { + const callbacks = { + onBatch: () => {}, + onSourceError: (error: Error) => sourceErrors.push(error), + } + if (path === `ordered`) { + return createEffect({ + query: (q) => + q + .from({ item: primary }) + .orderBy(({ item }) => item.rank, `asc`) + .limit(1), + ...callbacks, + }) + } + if (path === `lazy`) { + return createEffect({ + query: (q) => + q + .from({ item: primary }) + .leftJoin({ child }, ({ item, child: childRow }) => + eq(item.id, childRow.parentId), + ), + ...callbacks, + }) + } + return createEffect({ + query: (q) => q.from({ item: primary }), + ...callbacks, + }) +} + +function startLive( + path: StartupPath, + primary: RowCollection, + child: RowCollection, +) { + if (path === `ordered`) { + return createLiveQueryCollection((q) => + q + .from({ item: primary }) + .orderBy(({ item }) => item.rank, `asc`) + .limit(1), + ) + } + if (path === `lazy`) { + return createLiveQueryCollection((q) => + q + .from({ item: primary }) + .leftJoin({ child }, ({ item, child: childRow }) => + eq(item.id, childRow.parentId), + ), + ) + } + return createLiveQueryCollection((q) => q.from({ item: primary })) +} + +async function flushFailures() { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe(`loadSubset failure matrix`, () => { + it.each(startupCases)( + `releases startup ownership and reports the source error: $name`, + async ({ consumer, path, delivery }) => { + const error = new Error(`${consumer} ${path} startup failed`) + const suffix = `${consumer}-${path}-${delivery}` + const directOrOrderedSource = createFailingSource( + `failure-matrix-startup-primary-${suffix}`, + delivery, + error, + ) + const lazyParent = createStaticSource( + `failure-matrix-startup-parent-${suffix}`, + [row], + ) + const lazyChild = createFailingSource( + `failure-matrix-startup-child-${suffix}`, + delivery, + error, + ) + const primary = path === `lazy` ? lazyParent : directOrOrderedSource + const child = path === `lazy` ? lazyChild : directOrOrderedSource + + try { + if (consumer === `effect`) { + const sourceErrors: Array = [] + if (delivery === `throw`) { + expect(() => + startEffect(path, primary, child, sourceErrors), + ).toThrow(error) + } else { + const effect = startEffect(path, primary, child, sourceErrors) + await flushFailures() + expect(effect.disposed).toBe(true) + await effect.dispose() + } + expect(sourceErrors).toEqual([error]) + } else { + const live = startLive(path, primary, child) + try { + await expect( + Promise.resolve().then(() => live.preload()), + ).rejects.toBe(error) + expect(live.status).toBe(`error`) + } finally { + await live.cleanup() + } + } + + expect(primary.subscriberCount).toBe(0) + if (path === `lazy`) expect(child.subscriberCount).toBe(0) + } finally { + await Promise.all([ + directOrOrderedSource.cleanup(), + lazyParent.cleanup(), + lazyChild.cleanup(), + ]) + } + }, + ) + + it.each(incrementalCases)( + `reports an incremental failure without escaping its source commit: $name`, + async ({ consumer, path, delivery }) => { + const error = new Error(`${consumer} ${path} incremental failed`) + const suffix = `${consumer}-${path}-${delivery}` + let triggerFailure: () => void + let primary: RowCollection + let child: RowCollection + + if (path === `ordered`) { + let begin!: () => void + let write!: (message: { type: `insert` | `delete`; value: Row }) => void + let commit!: () => void + let loadCount = 0 + primary = createCollection({ + id: `failure-matrix-incremental-ordered-${suffix}`, + getKey: (item) => item.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount > 1) return fail(delivery, error) + begin() + write({ type: `insert`, value: row }) + commit() + return true + }, + } + }, + }, + }) + child = primary + triggerFailure = () => { + begin() + write({ type: `delete`, value: row }) + commit() + } + } else { + primary = createStaticSource( + `failure-matrix-incremental-parent-${suffix}`, + [], + ) + child = createFailingSource( + `failure-matrix-incremental-child-${suffix}`, + delivery, + error, + ) + triggerFailure = () => { + primary.utils.begin() + primary.utils.write({ type: `insert`, value: row }) + primary.utils.commit() + } + } + + try { + if (consumer === `effect`) { + const sourceErrors: Array = [] + const effect = startEffect(path, primary, child, sourceErrors) + try { + triggerFailure() + await flushFailures() + + expect(sourceErrors).toEqual([error]) + expect(effect.disposed).toBe(true) + } finally { + await effect.dispose() + } + } else { + const live = startLive(path, primary, child) + try { + await live.preload() + triggerFailure() + await flushFailures() + + expect(live.status).toBe(path === `lazy` ? `error` : `ready`) + expect(live.utils.lastSubsetError).toBe(error) + } finally { + await live.cleanup() + } + } + + expect(primary.subscriberCount).toBe(0) + if (path === `lazy`) expect(child.subscriberCount).toBe(0) + } finally { + await Promise.all( + primary === child + ? [primary.cleanup()] + : [primary.cleanup(), child.cleanup()], + ) + } + }, + ) +}) diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 9ebf93f7e..7b38d745d 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -573,12 +573,13 @@ function createLoadSubsetDedupe>({ } const loadSubset = async (opts: LoadSubsetOptions) => { + if (opts.signal?.aborted) return + if (isBufferingInitialSync()) { const snapshotParams = compileSQL(opts, compileOptions) try { const { data: rows } = await stream.fetchSnapshot(snapshotParams) - - if (!isBufferingInitialSync()) { + if (opts.signal?.aborted || !isBufferingInitialSync()) { debug(`${logPrefix}Ignoring snapshot - sync completed while fetching`) return } @@ -596,6 +597,7 @@ function createLoadSubsetDedupe>({ debug(`${logPrefix}Applied snapshot with ${rows.length} rows`) } } catch (error) { + if (opts.signal?.aborted) return if (handleSnapshotError(error, `fetchSnapshot`)) { return } @@ -643,6 +645,14 @@ function createLoadSubsetDedupe>({ } } + if (opts.signal?.aborted) return + + // Upstream limitation: ShapeStream.requestSnapshot() publishes its rows + // through the stream callback before its Promise resolves. It accepts no + // request signal and exposes no request identity on those messages, so an + // aborted request can already have installed rows before the check below. + // Full request-scoped cancellation requires support in the Electric client; + // matching snapshots by parameters is unsafe for overlapping equal requests. try { if (cursor) { const whereCurrentOpts: LoadSubsetOptions = { @@ -675,6 +685,7 @@ function createLoadSubsetDedupe>({ await stream.requestSnapshot(snapshotParams) } } catch (error) { + if (opts.signal?.aborted) return if (handleSnapshotError(error, `requestSnapshot`)) { return } diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 2e6ae54ef..06faf86a7 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ShapeStream } from '@electric-sql/client' import { CollectionImpl, @@ -21,6 +21,8 @@ import type { import type { Message, Row } from '@electric-sql/client' import type { StandardSchemaV1 } from '@standard-schema/spec' +const NativeAbortController = globalThis.AbortController + // Mock the ShapeStream module const mockSubscribe = vi.fn() const mockRequestSnapshot = vi.fn() @@ -1998,6 +2000,10 @@ describe(`Electric Integration`, () => { .mockImplementation(() => mockAbortController) }) + afterEach(() => { + globalThis.AbortController = NativeAbortController + }) + it(`should call unsubscribe and abort when collection is cleaned up`, async () => { const config = { id: `cleanup-test`, @@ -2899,6 +2905,64 @@ describe(`Electric Integration`, () => { }) }) + it(`ignores a progressive snapshot after its subset request is aborted`, async () => { + mockFetchSnapshot.mockReset() + let resolveSnapshot!: (value: { + metadata: Record + data: Array<{ + key: string + value: Row + headers: { operation: `insert` } + }> + }) => void + mockFetchSnapshot.mockReturnValue( + new Promise((resolve) => { + resolveSnapshot = resolve + }), + ) + mockSubscribe.mockImplementation(() => () => {}) + const testCollection = createCollection( + electricCollectionOptions({ + id: `progressive-aborted-snapshot-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `progressive`, + getKey: (item: Row) => item.id as number, + startSync: true, + }), + ) + const abortController = new AbortController() + + try { + expect(mockFetchSnapshot).not.toHaveBeenCalled() + const load = testCollection._sync.loadSubset({ + limit: 1, + signal: abortController.signal, + }) + expect(mockFetchSnapshot).toHaveBeenCalledOnce() + expect(testCollection.has(2)).toBe(false) + abortController.abort() + resolveSnapshot({ + metadata: {}, + data: [ + { + key: `2`, + value: { id: 2, name: `Obsolete snapshot` }, + headers: { operation: `insert` }, + }, + ], + }) + if (load instanceof Promise) await load + + expect(testCollection.has(2)).toBe(false) + } finally { + resolveSnapshot({ metadata: {}, data: [] }) + await testCollection.cleanup() + } + }) + it(`should not request snapshots when loadSubset is called in eager mode`, async () => { vi.clearAllMocks() diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts index 2ebcae19b..37c37a753 100644 --- a/packages/svelte-db/tests/hydration.svelte.test.ts +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { flushSync } from 'svelte' import { DbClient } from '@tanstack/db' import { useLiveQuery } from '../src/useLiveQuery.svelte.js' @@ -32,13 +32,12 @@ describe(`Svelte hydration`, () => { ]) resolveBrowserLoad() - await Promise.resolve() - await Promise.resolve() - flushSync() - - expect(query.data).toEqual([ - expect.objectContaining({ id: `browser`, name: `Browser source` }), - ]) + await vi.waitFor(() => { + flushSync() + expect(query.data).toEqual([ + expect.objectContaining({ id: `browser`, name: `Browser source` }), + ]) + }) dispose() }) }) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index e7b283e4a..1fa1c4bf8 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -200,7 +200,7 @@ export function trailBaseCollectionOptions< // Load (more) data. async function load(opts: LoadSubsetOptions) { - if (cancelled) return + if (cancelled || opts.signal?.aborted) return const lastKey = opts.cursor?.lastKey let cursor: string | undefined = @@ -221,16 +221,22 @@ export function trailBaseCollectionOptions< while (true) { const limit = Math.min(remaining, 256) - const response = await config.recordApi.list({ - pagination: { - limit, - offset, - cursor, - }, - order, - filters, - }) - if (cancelled) return + let response + try { + response = await config.recordApi.list({ + pagination: { + limit, + offset, + cursor, + }, + order, + filters, + }) + } catch (error) { + if (cancelled || opts.signal?.aborted) return + throw error + } + if (cancelled || opts.signal?.aborted) return const length = response.records.length if (length === 0) { diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index 7f0858932..93f4e8124 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -179,6 +179,46 @@ describe(`TrailBase Integration`, () => { }) }) + it(`ignores a subset page that resolves after its request is aborted`, async () => { + const recordApi = new MockRecordApi() + let resolveList!: (response: ListResponse) => void + recordApi.list.mockReturnValue( + new Promise>((resolve) => { + resolveList = resolve + }), + ) + recordApi.subscribe.mockResolvedValue(new TransformStream().readable) + const collection = createCollection( + trailBaseCollectionOptions({ + recordApi, + getKey: (item: Data) => item.id ?? -1, + startSync: true, + syncMode: `on-demand`, + parse: {}, + serialize: {}, + }), + ) + const abortController = new AbortController() + + try { + await vi.waitFor(() => expect(collection.status).toBe(`ready`)) + const load = collection._sync.loadSubset({ + signal: abortController.signal, + }) + expect(recordApi.list).toHaveBeenCalledOnce() + abortController.abort() + resolveList({ + records: [{ id: 1, updated: 0, data: `obsolete` }], + }) + if (load instanceof Promise) await load + + expect(stripState(collection.state)).toEqual(new Map()) + } finally { + resolveList({ records: [] }) + await collection.cleanup() + } + }) + it(`initial fetch, receive update and cancel`, async () => { const records: Array = [ {